DEL Minichallenge - Dog Breed Classification

Author

Stefan Binkert

Published

November 18, 2025

Happy Dogs

Einführung

Hintergrund & Zielsetzung

In diesem Projekt wollen wir ein Modell trainieren, das Hunderassen aus Bildern erkennt. Der Fokus liegt dabei weniger auf dem reinen Erreichen einer maximalen Accuracy, sondern darauf, verschiedene Modelle und Methoden systematisch auszuprobieren und zu verstehen, wie sie sich auf die Performance auswirken. Wir arbeiten dabei iterativ: eine einfache Baseline aufbauen, Schwachstellen erkennen, Hypothesen formulieren und diese dann experimentell überprüfen.

Wahl des Datensatzes

Wir verwenden das Stanford Dogs Dataset (http://vision.stanford.edu/aditya86/ImageNetDogs), weil es mit seinen vielen Rassen und relativ ähnlichen Klassen eine echte Herausforderung darstellt. Gleichzeitig ist der Datensatz gut dokumentiert und häufig in Computer-Vision-Beispielen zu finden, was ihn ideal dafür macht, unterschiedliche Architekturen, Augmentation-Strategien und Optimizer-Einstellungen zu testen. Kurz: anspruchsvoll genug, um Unterschiede sichtbar zu machen, aber praktisch handhabbar.

Projektüberblick

Im Verlauf des Notebooks gehen wir Schritt für Schritt vor: Wir schauen uns den Datensatz an, bereiten ihn mit passenden Transformationen vor und definieren ein erstes Basismodell. Danach untersuchen wir, welche Hyperparameter und Modellvarianten sich wie auswirken. Zusätzlich testen wir klassische Verbesserungen wie Dropout, BatchNorm oder He-Initialisierung und später auch Transfer Learning mit vortrainierten Modellen. Am Ende vergleichen wir alle Varianten und wählen ein Modell, das sich in unseren Experimenten am besten bewährt.

# Import the core standard-library helpers plus visualization, ML, and helper utilities reused everywhere.
import importlib
import json
import math
import os
import shutil
import time
from contextlib import nullcontext
from itertools import product

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns

from sklearn.model_selection import KFold

import torch
import torch.nn as nn
import torch.optim as optim
from PIL import Image
from torch.utils.data import DataLoader, TensorDataset, random_split
from torch.utils.tensorboard import SummaryWriter
from torchvision import transforms

import helper_utils

# Reload helper functions so edits in helper_utils.py are immediately reflected.
importlib.reload(helper_utils)
<module 'helper_utils' from '/Users/stefanbinkert/Documents/FHNW_DS/DEL/DEL_DOGS/helper_utils.py'>
# Select the best available accelerator (CUDA > Apple Metal (MPS) > CPU) for every training run.
if torch.cuda.is_available():
    device = torch.device("cuda")
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
    device = torch.device("mps")
else:
    device = torch.device("cpu")

# Show the decision so later cells and logs remain traceable.
print("Using device:", device)
Using device: mps

Explorative Datenanalyse (EDA)

Datenstruktur untersuchen

Bevor wir loslegen, schauen wir uns zuerst an, wie der Datensatz aufgebaut ist. Wir prüfen die Ordnerstruktur, die Anzahl Klassen und wie viele Bilder pro Klasse vorhanden sind. Dadurch bekommen wir ein Gefühl dafür, ob die Daten halbwegs sauber organisiert sind und ob wir später evtl. mit Ungleichgewichten umgehen müssen.

# Point to the raw Dog Breed dataset root and print its top-level structure for sanity.
path_dataset = "data/raw/Images"

# Display the nested folders so we can quickly verify breeds/classes are present.
helper_utils.print_data_folder_structure(path_dataset, max_depth=1)
Images/
├── n02085620-Chihuahua/
├── n02085782-Japanese_spaniel/
├── n02085936-Maltese_dog/
├── n02086079-Pekinese/
├── n02086240-Shih-Tzu/
├── n02086646-Blenheim_spaniel/
├── n02086910-papillon/
├── n02087046-toy_terrier/
├── n02087394-Rhodesian_ridgeback/
├── n02088094-Afghan_hound/
├── n02088238-basset/
├── n02088364-beagle/
├── n02088466-bloodhound/
├── n02088632-bluetick/
├── n02089078-black-and-tan_coonhound/
├── n02089867-Walker_hound/
├── n02089973-English_foxhound/
├── n02090379-redbone/
├── n02090622-borzoi/
├── n02090721-Irish_wolfhound/
├── n02091032-Italian_greyhound/
├── n02091134-whippet/
├── n02091244-Ibizan_hound/
├── n02091467-Norwegian_elkhound/
├── n02091635-otterhound/
├── n02091831-Saluki/
├── n02092002-Scottish_deerhound/
├── n02092339-Weimaraner/
├── n02093256-Staffordshire_bullterrier/
├── n02093428-American_Staffordshire_terrier/
├── n02093647-Bedlington_terrier/
├── n02093754-Border_terrier/
├── n02093859-Kerry_blue_terrier/
├── n02093991-Irish_terrier/
├── n02094114-Norfolk_terrier/
├── n02094258-Norwich_terrier/
├── n02094433-Yorkshire_terrier/
├── n02095314-wire-haired_fox_terrier/
├── n02095570-Lakeland_terrier/
├── n02095889-Sealyham_terrier/
├── n02096051-Airedale/
├── n02096177-cairn/
├── n02096294-Australian_terrier/
├── n02096437-Dandie_Dinmont/
├── n02096585-Boston_bull/
├── n02097047-miniature_schnauzer/
├── n02097130-giant_schnauzer/
├── n02097209-standard_schnauzer/
├── n02097298-Scotch_terrier/
├── n02097474-Tibetan_terrier/
├── n02097658-silky_terrier/
├── n02098105-soft-coated_wheaten_terrier/
├── n02098286-West_Highland_white_terrier/
├── n02098413-Lhasa/
├── n02099267-flat-coated_retriever/
├── n02099429-curly-coated_retriever/
├── n02099601-golden_retriever/
├── n02099712-Labrador_retriever/
├── n02099849-Chesapeake_Bay_retriever/
├── n02100236-German_short-haired_pointer/
├── n02100583-vizsla/
├── n02100735-English_setter/
├── n02100877-Irish_setter/
├── n02101006-Gordon_setter/
├── n02101388-Brittany_spaniel/
├── n02101556-clumber/
├── n02102040-English_springer/
├── n02102177-Welsh_springer_spaniel/
├── n02102318-cocker_spaniel/
├── n02102480-Sussex_spaniel/
├── n02102973-Irish_water_spaniel/
├── n02104029-kuvasz/
├── n02104365-schipperke/
├── n02105056-groenendael/
├── n02105162-malinois/
├── n02105251-briard/
├── n02105412-kelpie/
├── n02105505-komondor/
├── n02105641-Old_English_sheepdog/
├── n02105855-Shetland_sheepdog/
├── n02106030-collie/
├── n02106166-Border_collie/
├── n02106382-Bouvier_des_Flandres/
├── n02106550-Rottweiler/
├── n02106662-German_shepherd/
├── n02107142-Doberman/
├── n02107312-miniature_pinscher/
├── n02107574-Greater_Swiss_Mountain_dog/
├── n02107683-Bernese_mountain_dog/
├── n02107908-Appenzeller/
├── n02108000-EntleBucher/
├── n02108089-boxer/
├── n02108422-bull_mastiff/
├── n02108551-Tibetan_mastiff/
├── n02108915-French_bulldog/
├── n02109047-Great_Dane/
├── n02109525-Saint_Bernard/
├── n02109961-Eskimo_dog/
├── n02110063-malamute/
├── n02110185-Siberian_husky/
├── n02110627-affenpinscher/
├── n02110806-basenji/
├── n02110958-pug/
├── n02111129-Leonberg/
├── n02111277-Newfoundland/
├── n02111500-Great_Pyrenees/
├── n02111889-Samoyed/
├── n02112018-Pomeranian/
├── n02112137-chow/
├── n02112350-keeshond/
├── n02112706-Brabancon_griffon/
├── n02113023-Pembroke/
├── n02113186-Cardigan/
├── n02113624-toy_poodle/
├── n02113712-miniature_poodle/
├── n02113799-standard_poodle/
├── n02113978-Mexican_hairless/
├── n02115641-dingo/
├── n02115913-dhole/
└── n02116738-African_hunting_dog/
# Import dataset utilities inside the notebook so dataloader workers can pickle/resolve them.
from helper_utils import DogDataset, SubsetWithTransform
# Instantiate the dataset once (so caches/indexing are shared) and inspect how many samples exist.
dog_dataset = DogDataset()
num_samples = len(dog_dataset)
print(f"Number of samples in dataset: {num_samples}")
Number of samples in dataset: 20580

Klassenübersicht & Verteilung

Anschliessend visualisieren wir die Klassen und deren Verteilung. Der Stanford Dogs Dataset ist grundsätzlich recht ausgewogen, aber kleine Unterschiede gibt es trotzdem.
Mit den Beispielbilder können wir einschätzen, wie gross die Variation ist – also Licht, Pose, Hintergründe usw. Das hilft uns später, passende Augmentation-Strategien zu wählen.

# Visualize a grid of representative dog images to qualitatively inspect the raw data.
helper_utils.plot_group_overview_grid()

# Plot the class distribution to identify any class imbalance before modeling.
helper_utils.plot_class_distribution(dog_dataset)

Datenvorverarbeitung

Berechnung von Mean & Standardabweichung

Bevor wir die eigentlichen Transformations definieren, berechnen wir zuerst den Mean und die Standardabweichung des Trainsets. Das brauchen wir später für eine saubere Normalisierung, damit alle Bilder auf einer vergleichbaren Skala liegen.
Dafür resizen wir die Bilder einmal grob aufs Ziel-Format, wandeln sie in Tensoren um und lassen dann für jedes Bild die Kanal-Statistiken ausrechnen. Am Ende mitteln wir alles, um die globalen Werte für das ganze Trainset zu bekommen.
Wichtig: Wir machen das nach dem Split, damit es keine Datenlecks gibt.

# Split the dataset into train / validation / test subsets and cache normalization statistics.
full_dataset = dog_dataset

# Fractions remain configurable so experiments can rebalance splits without touching the code.
val_fraction = 0.15
test_fraction = 0.15
batch_size = 64

# Translate fractions into concrete subset sizes.
total_size = len(full_dataset)
val_size = int(total_size * val_fraction)
test_size = int(total_size * test_fraction)
train_size = total_size - val_size - test_size

# Randomly partition the dataset into the computed lengths.
train_subset, val_subset, test_subset = random_split(
    full_dataset,
    [train_size, val_size, test_size],
)

# Get mean and std from the train dataset for later use in Normalize()
# mean, std = helper_utils.get_mean_std(train_subset)

# Hardcode mean and std (precomputed offline) to keep every notebook run deterministic.
mean = torch.tensor([0.4760, 0.4521, 0.3909])
std = torch.tensor([0.2544, 0.2488, 0.2536])
print(f"Mean: {mean}, Std: {std}")
Mean: tensor([0.4760, 0.4521, 0.3909]), Std: tensor([0.2544, 0.2488, 0.2536])

Transformations definieren

Auf Basis dieser Werte bauen wir dann zwei Transformations-Pipelines:

  • Training: enthält Resize, Tensor-Konvertierung, Normalisierung und zusätzlich Augmentation wie RandomHorizontalFlip oder kleine RandomRotation. So erhöhen wir die Vielfalt im Trainset und machen das Modell robuster.
  • Validation/Test: hier nutzen wir nur die „sauberen“ Transformations ohne Augmentation, damit die Evaluation fair und reproduzierbar bleibt.

Beide Pipelines packen wir in ein transforms.Compose, damit jedes Bild automatisch gleich verarbeitet wird.

# Build preprocessing transforms: deterministic base transforms + optional augmentation for training.
main_tfs = [
    # Resize images to 128x128 pixels
    transforms.Resize(128),
    # Apply a centered crop to clean up aspect ratios
    transforms.CenterCrop(128),
    # Convert images to PyTorch tensors
    transforms.ToTensor(),
    # Normalize images using the provided mean and std
    transforms.Normalize(mean, std),
]

augmentation_tfs = [
    # Randomly flip the image horizontally to mimic mirrored poses
    transforms.RandomHorizontalFlip(p=0.5),
    # Randomly rotate the image by +/-15 degrees for viewpoint robustness
    transforms.RandomRotation(degrees=15),
]

# Compose the transformation pipelines used for (a) vanilla evaluation and (b) augmented training batches.
main_transform = transforms.Compose(main_tfs)
transform_with_augmentation = transforms.Compose(augmentation_tfs + main_tfs)

Data Loading

Nachdem Dataset und Transformations stehen, splitten wir den Datensatz in Train-, Validation- und Testset.
Da random_split alle Subsets auf dasselbe ursprüngliche Dataset verweist (inkl. transform), verwenden wir eine kleine Hilfsklasse, damit wir pro Split eigene Transformations setzen können. Also Augmentation nur für Training, und die anderen beiden bleiben unverändert.

Anschliessend erstellen wir für alle drei Teile passende DataLoader.
Die kümmern sich dann um batching, shuffling (nur im Training) und um das effiziente Nachladen während des Trainings. Damit ist sichergestellt, dass das Modell die Daten konsistent und performant erhält.

Performance Optimierung

Damit das Training auf unserem Setup möglichst flüssig läuft, optimieren wir ein paar Punkte rund um das Data Loading und das eigentliche Training. Da wir auf einem Mac arbeiten, nutzen wir Apple’s MPS Backend, das inzwischen recht gute GPU-ähnliche Beschleunigung bietet.

Neben MPS gibt es noch ein paar weitere Stellschrauben:

  • num_workers
    Wir setzen mehrere Worker-Prozesse für das Laden der Daten ein. Das sorgt dafür, dass die CPU parallel vorbereiten kann, während die GPU bzw. MPS schon am Trainieren ist.

  • persistent_workers
    Wenn wir dieses Flag aktivieren, bleiben die Worker über die gesamten Epochen hinweg bestehen. Dadurch entfällt das ständige Neu-Starten der Prozesse zwischen den Epochen, was das Training merklich beschleunigt.

  • prefetch_factor
    Hier geben wir an, wie viele Batches jeder Worker im Voraus laden soll. Ein kleiner Puffer hilft dabei, die GPU/MPS besser auszulasten und Leerlauf zu vermeiden.

  • amp_autocast
    Durch Automatic Mixed Precision (AMP) können wir mit Float16 rechnen, wo es sicher ist, und mit Float32, wo es notwendig ist. Auf MPS funktioniert das inzwischen ziemlich gut und spart spürbar Rechenzeit – bei gleichzeitig geringerer Speichernutzung.

Mit diesen Einstellungen erreichen wir deutlich schnellere Trainingszeiten und eine angenehmere Entwicklungsschleife, ohne dass wir am Modell selbst etwas ändern müssen.

# Configure dataloaders (with/without augmentation) and tune worker settings for throughput.
NUM_WORKERS = 10
PREFETCH = 4

# Wrap subsets with the appropriate transform pipeline.
trainset_with_aug = SubsetWithTransform(train_subset, transform_with_augmentation)
trainset = SubsetWithTransform(train_subset, main_transform)
validationset = SubsetWithTransform(val_subset, main_transform)
testset = SubsetWithTransform(test_subset, main_transform)

# Create dataloaders for each split. Shuffle the training data and keep eval loaders deterministic.
trainloader_with_aug = DataLoader(
    trainset_with_aug,
    batch_size=batch_size,
    shuffle=True,
    num_workers=NUM_WORKERS,
    persistent_workers=True,
    prefetch_factor=PREFETCH,
)
trainloader = DataLoader(
    trainset,
    batch_size=batch_size,
    shuffle=True,
    num_workers=NUM_WORKERS,
    persistent_workers=True,
    prefetch_factor=PREFETCH,
)
validationloader = DataLoader(
    validationset,
    batch_size=512,
    shuffle=False,
    num_workers=NUM_WORKERS,
    persistent_workers=True,
    prefetch_factor=PREFETCH,
)
testloader = DataLoader(
    testset,
    batch_size=512,
    shuffle=False,
    num_workers=NUM_WORKERS,
    persistent_workers=True,
    prefetch_factor=PREFETCH,
)

# Quick sanity checks on dataset sizes to catch split mistakes early.
print("Train samples:", len(trainset), "  batches:", len(trainloader))
print("Val   samples:", len(validationset), "  batches:", len(validationloader))
print("Test  samples:", len(testset), "  batches:", len(testloader))
Train samples: 14406   batches: 226
Val   samples: 3087   batches: 7
Test  samples: 3087   batches: 7

Modellarchitektur: Baseline CNN

Auswahl der Metriken

Für die Bewertung unseres Modells verwenden wir Accuracy, da wir es hier mit einem klassischen multi-class Classification-Problem zu tun haben und die Klassen im Stanford Dogs Dataset relativ ausgeglichen sind. Zusätzlich schauen wir uns im Training natürlich auch Loss-Kurven an, um Overfitting oder Instabilitäten früh zu erkennen.

Implementierung des Basismodells

Als Startpunkt entwickeln wir ein einfaches Baseline CNN, das aus ein paar Convolution-Blöcken mit ReLU und MaxPool besteht, gefolgt von einem kleinen Fully-Connected-Head. Die Idee ist bewusst simpel: Wir wollen zuerst ein Modell haben, das funktioniert, aber noch genug Raum für Verbesserungen lässt.
Dieses Basismodell dient uns später als Vergleichspunkt für alle weiteren Varianten wie BatchNorm, Dropout oder andere Architekturen.

class BasicCNN(nn.Module):
    """
    A baseline CNN for 3x128x128 dog images using conv-ReLU-pool blocks.

    Args:
        num_classes (int): Number of output classes produced by the classifier.
    """

    def __init__(self, num_classes: int = 120):
        """
        Build the convolutional feature extractor and classifier head.

        Args:
            num_classes (int): Number of dog breeds to predict.

        Returns:
            None
        """
        super().__init__()

        # Stack three conv blocks that incrementally expand the channel width.
        self.conv1 = nn.Conv2d(3, 32, kernel_size=3, padding=1)
        self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
        self.conv3 = nn.Conv2d(64, 128, kernel_size=3, padding=1)
        self.pool = nn.MaxPool2d(2, 2)
        self.relu = nn.ReLU(inplace=True)

        # For 128x128 input: after 3 pooling layers -> 128 -> 64 -> 32 -> 16.
        self.flatten_dim = 128 * 16 * 16

        # Classification head converts flattened features to logits.
        self.flatten = nn.Flatten()
        self.fc1 = nn.Linear(self.flatten_dim, 256)
        self.fc2 = nn.Linear(256, num_classes)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """
        Run the forward pass through the CNN.

        Args:
            x (torch.Tensor): Batch of images shaped ``(N, 3, 128, 128)``.

        Returns:
            torch.Tensor: Logits of shape ``(N, num_classes)``.
        """
        # Apply each conv -> ReLU -> pool block before flattening.
        x = self.pool(self.relu(self.conv1(x)))
        x = self.pool(self.relu(self.conv2(x)))
        x = self.pool(self.relu(self.conv3(x)))
        x = self.flatten(x)
        x = self.relu(self.fc1(x))
        # Final linear layer outputs breed logits.
        return self.fc2(x)

Trainings- & Evaluationsfunktionen

Damit wir alle Modelle einheitlich trainieren können, schreiben wir generische Funktionen für das Training und die Evaluation.
Die Training-Funktion kümmert sich um Forward-Pass, Loss-Berechnung, Backpropagation und das Loggen von Accuracy und Loss.
Die Eval-Funktion läuft ohne Gradients und misst nur Performance und Loss auf dem Validation-Set.

Mit diesen Funktionen können wir später bequem verschiedene Modellvarianten durchlaufen lassen, ohne jedes Mal den Trainingscode anzupassen.

# Utility context manager: enable automatic mixed precision when the target device supports it.
def _amp_autocast(device):
    if device.type in ("mps", "cuda"):
        dtype = torch.bfloat16 if device.type == "mps" else torch.float16
        return torch.autocast(device_type=device.type, dtype=dtype)
    return nullcontext()


def train_epoch(model, dataloader, optimizer, loss_fcn, device):
    """Run one full pass over the training loader."""
    model.train()
    running_loss = 0.0
    correct = 0
    samples = 0

    for inputs, targets in dataloader:
        # Move the mini-batch onto the active accelerator.
        inputs = inputs.to(device)
        targets = targets.to(device)

        optimizer.zero_grad(set_to_none=True)
        with _amp_autocast(device):
            outputs = model(inputs)
            loss = loss_fcn(outputs, targets)
        loss.backward()
        optimizer.step()

        # Track total loss/accuracy so epoch-level metrics can be computed later.
        running_loss += loss.item() * inputs.size(0)
        predictions = outputs.argmax(dim=1)
        correct += (predictions == targets).sum().item()
        samples += targets.size(0)

    avg_loss = running_loss / max(samples, 1)
    accuracy = correct / max(samples, 1)
    return avg_loss, accuracy


def validate_epoch(model, dataloader, loss_fcn, device):
    """Evaluate the model without gradient tracking."""
    model.eval()
    running_loss = 0.0
    correct = 0
    samples = 0

    with torch.no_grad():
        for inputs, targets in dataloader:
            inputs = inputs.to(device)
            targets = targets.to(device)

            with _amp_autocast(device):
                outputs = model(inputs)
                loss = loss_fcn(outputs, targets)
            running_loss += loss.item() * inputs.size(0)

            predictions = outputs.argmax(dim=1)
            correct += (predictions == targets).sum().item()
            samples += targets.size(0)

    avg_loss = running_loss / max(samples, 1)
    accuracy = correct / max(samples, 1)
    return avg_loss, accuracy


def train_model(
    model,
    optimizer,
    train_dataloader,
    n_epochs,
    loss_fcn,
    device,
    val_dataloader=None,
    writer=None,
    lr_scheduler=None,
):
    """Generic training loop shared by every experiment in the notebook."""
    history = []
    for epoch in range(1, n_epochs + 1):
        start = time.perf_counter()
        train_loss, train_acc = train_epoch(
            model=model,
            dataloader=train_dataloader,
            optimizer=optimizer,
            loss_fcn=loss_fcn,
            device=device,
        )

        metrics = {
            "epoch": epoch,
            "train_loss": train_loss,
            "train_accuracy": train_acc,
        }

        if val_dataloader is not None:
            val_loss, val_acc = validate_epoch(
                model=model,
                dataloader=val_dataloader,
                loss_fcn=loss_fcn,
                device=device,
            )
            metrics["val_loss"] = val_loss
            metrics["val_accuracy"] = val_acc

        duration = time.perf_counter() - start
        metrics["epoch_time_sec"] = duration
        history.append(metrics)

        if writer is not None:
            if val_dataloader is not None:
                writer.add_scalars(
                    "Loss",
                    {"train": train_loss, "val": metrics["val_loss"]},
                    epoch,
                )
                writer.add_scalars(
                    "Accuracy",
                    {"train": train_acc, "val": metrics["val_accuracy"]},
                    epoch,
                )
            else:
                writer.add_scalar("Loss/train", train_loss, epoch)
                writer.add_scalar("Accuracy/train", train_acc, epoch)

            writer.add_scalar("Timing/epoch_seconds", duration, epoch)

        if lr_scheduler is not None:
            lr_scheduler.step()

    return history

Overfitting-Test

Training auf einem Batch

Bevor wir das Basismodell auf den ganzen Datensatz loslassen, führen wir einen kurzen Overfitting-Test durch. Die Idee dahinter ist einfach: Wenn wir dem Modell nur einen einzigen Batch geben, sollte es in der Lage sein, diesen komplett auswendig zu lernen.
Dafür trainieren wir unser Baseline CNN mit einem sehr kleinen Trainingsset (nur ein Batch) über mehrere Epochen und beobachten, ob Accuracy und Loss in Richtung 100 % bzw. 0 wandern.

# Overfitting test on a single batch to validate the training loop end-to-end.
overfit_epochs = 100
# Grab a deterministic batch so each epoch (and rerun) sees the very same data.
trainloader_overfit = DataLoader(
    trainset,
    batch_size=batch_size,
    shuffle=False,
    num_workers=NUM_WORKERS,
    persistent_workers=True,
    prefetch_factor=PREFETCH,
)
batch_inputs, batch_targets = next(iter(trainloader_overfit))
# Clone tensors so later augmentations (if any) do not mutate the cached batch.
single_dataset = TensorDataset(batch_inputs.clone(), batch_targets.clone())
single_loader = DataLoader(single_dataset, batch_size=batch_inputs.size(0))

overfit_model = BasicCNN().to(device)
overfit_optimizer = optim.SGD(overfit_model.parameters(), lr=0.01)
loss_fcn = nn.CrossEntropyLoss()

writer = SummaryWriter(log_dir="runs/overfit")

overfit_history = train_model(
    model=overfit_model,
    optimizer=overfit_optimizer,
    train_dataloader=single_loader,
    n_epochs=overfit_epochs,
    loss_fcn=loss_fcn,
    device=device,
    val_dataloader=single_loader,
    writer=writer,
)
final_overfit = overfit_history[-1]
writer.close()

print(
    f"Overfit run final accuracy: {final_overfit['train_accuracy']:.3%} "
    f"(val {final_overfit['val_accuracy']:.3%})"
)
Overfit run final accuracy: 100.000% (val 100.000%)
# Visualize the training/validation curves from the single-batch overfitting sanity check.
helper_utils.plot_learning_curves("overfit")

Analyse des Ergebnisses

Wenn das Modell tatsächlich „überfittet“, wissen wir, dass unser Trainingsloop, die Loss-Berechnung, der Optimizer und die Backpropagation korrekt funktionieren.
In unserem Fall klappt das erwartungsgemäss: Das Modell lernt den Mini-Datensatz fast perfekt. Damit haben wir bestätigt, dass der Grundaufbau stimmt und wir ohne technische Fehler weiterarbeiten können.

Training des Basismodells

Setup

Für das erste vollständige Training unseres Baseline CNN verwenden wir SGD ohne Momentum, eine moderate Learning Rate und eine Batch Size, die gut zu unserem MPS-Setup passt. Die Idee ist hier nicht, sofort maximale Performance zu erzielen, sondern eine stabile Ausgangsbasis zu schaffen, auf der wir später aufbauen können.

Nachdem das Modell und die Transformations definiert sind, trainieren wir das Basismodell über mehrere Epochen auf dem gesamten Trainset. Parallel messen wir die Performance auf dem Validation-Set, um früh zu erkennen, ob das Modell sinnvoll lernt oder schon in Richtung Overfitting läuft.

# Train the baseline model on the full training set with plain SGD.
baseline_epochs = 10
baseline_model = BasicCNN().to(device)
baseline_optimizer = optim.SGD(baseline_model.parameters(), lr=0.01)
loss_fcn = nn.CrossEntropyLoss()
baseline_writer = SummaryWriter(log_dir="runs/baseline")

baseline_history = train_model(
    model=baseline_model,
    optimizer=baseline_optimizer,
    train_dataloader=trainloader,
    n_epochs=baseline_epochs,
    loss_fcn=loss_fcn,
    device=device,
    val_dataloader=validationloader,
    writer=baseline_writer,
)

baseline_writer.close()

final_baseline = baseline_history[-1]
print(
    "Baseline training finished:",
    f" train acc {final_baseline['train_accuracy']:.3%},",
    f" val acc {final_baseline['val_accuracy']:.3%},",
)
Baseline training finished:  train acc 11.877%,  val acc 1.976%,
# Plot the loss/accuracy curves for the baseline configuration to inspect convergence.
helper_utils.plot_learning_curves("baseline")

Visualisierung der Lernkurven

Die Lernkurven zeigen sehr deutlich, dass die Baseline noch weit von guter Performance entfernt ist.
Der Training Loss sinkt zwar stetig und die Training Accuracy steigt sichtbar, aber die Validation Accuracy bleibt fast flach und pendelt um wenige Prozent. Gleichzeitig steigt der Validation Loss über die Epochen hinweg sogar leicht an.

Dieses Verhalten ist typisch für ein Modell, das zwar ein bisschen auswendig lernt, aber kaum generalisiert – ein klarer Hinweis darauf, dass unsere Baseline architektonisch und regularisierungstechnisch noch viel Luft nach oben hat.

Erste Interpretation

Die Resultate bestätigen, was wir erwartet haben: Ein einfaches CNN ohne BatchNorm, Dropout oder andere Tricks hat Mühe, die vielen feingranularen Klassen des Stanford Dogs Dataset auseinanderzuhalten.
Die Baseline erfüllt damit genau ihren Zweck: Sie zeigt uns, wo die Schwachstellen liegen und in welchen Bereichen wir mit gezielten Hypothesen und Modellvarianten nachbessern müssen.

Hyperparameter Grid Search (LR & Batch Size)

Motivation & Hypothesen

Bevor wir grössere Architekturänderungen angehen, wollen wir zuerst verstehen, wie sensibel unser Basismodell auf unterschiedliche Learning Rates und Batch Sizes reagiert. Unsere Hypothese ist, dass eine etwas kleinere Learning Rate stabileres Lernen ermöglicht und dass eine grössere Batch Size zu gleichmässigeren Gradienten führt – was besonders bei einem eher simplen CNN hilfreich sein kann.

Ergebnisse & Lernkurven

Die Lernkurven zeigen unterschiedliche Muster:

  • Hohe Learning Rates (0.03, 0.01)
    Das Modell lernt sehr schnell, steigt aber schnell in die Instabilität. Die Training Accuracy geht fast auf 100 %, während die Validation Accuracy bei 7–9 % hängen bleibt.
    Das ist ein klares Zeichen für starkes Overfitting.

  • Mittlere Learning Rate (0.01)
    Mit batch_size 64 sinkt die Stabilität sichtbar, und der Validation Loss steigt über die Epochen hinweg deutlich an.

  • Kleine Learning Rate (0.003)
    Hier sehen wir das stabilste Verhalten: Die Loss-Kurven sind glatter, es gibt weniger explosionsartige Ausreisser, und die Validation-Kurven bleiben vergleichsweise sauber.

Auch die tabellarischen Resultate unterstreichen das Bild:

LR BS Train Acc Val Acc Kommentar
0.03 32/64 ~99% ~8% extrem überfit, instabil
0.01 64 90% 3.9% instabil, hoher val loss
0.003 64 niedrig 2.6% sauberste & stabilste Lernkurve
0.003 32 etwas besserer val_loss 2.6% zeitlich ineffizienter

Baseline-Training mit 50 Epochen

Bevor wir komplexere Modelle testen, lassen wir die ursprüngliche Baseline-Architektur nochmals mit einem längeren Training laufen (50 Epochen, lr=0.003, bs=64).

# Re-train the baseline CNN with the best (lr, batch size) combo from the grid-search results.
baseline_epochs = 50
learning_rate = 0.003
batch_size = 64

# Recreate dataloaders with the chosen batch size so epoch steps align with the new setting.
trainloader = DataLoader(
    trainset,
    batch_size=batch_size,
    shuffle=True,
    num_workers=NUM_WORKERS,
    persistent_workers=True,
    prefetch_factor=PREFETCH,
)

# Initialize model, optimizer, loss function, and TensorBoard writer.
baseline_model = BasicCNN().to(device)
baseline_optimizer = optim.SGD(baseline_model.parameters(), lr=learning_rate)
loss_fcn = nn.CrossEntropyLoss()

baseline_writer = SummaryWriter(log_dir=f"runs/baseline_lr{learning_rate}_bs{batch_size}")

# Train model
baseline_history = train_model(
    model=baseline_model,
    optimizer=baseline_optimizer,
    train_dataloader=trainloader,
    n_epochs=baseline_epochs,
    loss_fcn=loss_fcn,
    device=device,
    val_dataloader=validationloader,
    writer=baseline_writer,
)

# Log hyperparameters and final metrics
final_baseline = baseline_history[-1]
baseline_writer.close()

# Print summary
print(
    f"Baseline training finished "
    f"(SGD | lr={learning_rate} | bs={batch_size} | epochs={baseline_epochs}):"
    f"  Train Accuracy: {final_baseline['train_accuracy']:.3%}"
    f"  Val Accuracy:   {final_baseline['val_accuracy']:.3%}")
Baseline training finished (SGD | lr=0.003 | bs=64 | epochs=50):
  Train Accuracy: 99.584%
  Val Accuracy:   6.544%
# Plot learning curves for the tuned baseline run to visually confirm the configuration choice.
helper_utils.plot_learning_curves(f"baseline_lr{learning_rate}_bs{batch_size}")

Ergebnisse

Das Modell zeigt ein typisches Overfitting-Verhalten:

  • Train Accuracy: steigt bis auf über 99 %, das Modell memorisiert das Trainset fast vollständig.
  • Validation Accuracy: bleibt dagegen sehr tief und erreicht nur rund 6.5 %.
  • Train Loss: fällt gegen null, typisch für starkes Memorieren.
  • Val Loss: steigt über die Epochen deutlich an und wird immer unruhiger.

Damit bestätigt das 50-Epoch-Baseline-Training die Vermutung aus den früheren Runs:
Die Architektur ist zu simpel und verfügt nicht über die notwendige Repräsentationsfähigkeit, um die 120 Hunderassen sinnvoll zu unterscheiden. Selbst mit längerer Trainingszeit nimmt die Generalisierung nicht zu, sondern verschlechtert sich weiter.

Statistische Fehlerschätzung

Setup

Um besser einschätzen zu können, wie stabil unser Baseline-Modell wirklich ist, führen wir eine 5-Fold Cross-Validation durch.
So sehen wir nicht nur ein einzelnes Resultat, sondern können messen, wie stark die Performance zwischen unterschiedlichen Splits schwankt. Das gibt uns ein realistischeres Bild davon, was das Modell tatsächlich kann – oder eben noch nicht kann.

Wir verwenden unser bisheriges Setup mit lr = 0.003 und batch_size = 64 und trainieren das Modell in jedem Fold für 30 Epochen.
Alle Folds verwenden die gleichen Hyperparameter und Trainingsroutinen, unterscheiden sich aber in der konkreten Aufteilung in Train- und Validation-Anteil.

# 5-fold cross-validation on the training set to estimate generalization uncertainty (includes statistical error).
n_splits = 5
cv_epochs = 30
learning_rate = 0.003
batch_size = 64
rng_seed = 42

kf = KFold(n_splits=n_splits, shuffle=True, random_state=rng_seed)

fold_metrics = []
print(f"CV: {n_splits} folds x {cv_epochs} epochs | lr={learning_rate}, bs={batch_size}")

for fold, (train_idx, val_idx) in enumerate(kf.split(trainset), 1):
    print(f"- Fold {fold}/{n_splits} ---")
    train_subset = torch.utils.data.Subset(trainset, train_idx)
    val_subset = torch.utils.data.Subset(trainset, val_idx)

    train_loader = DataLoader(
        train_subset,
        batch_size=batch_size,
        shuffle=True,
        num_workers=NUM_WORKERS,
        persistent_workers=True,
        prefetch_factor=PREFETCH,
    )
    val_loader = DataLoader(
        val_subset,
        batch_size=512,
        shuffle=False,
        num_workers=NUM_WORKERS,
        persistent_workers=True,
        prefetch_factor=PREFETCH,
    )

    model = BasicCNN().to(device)
    optimizer = optim.SGD(model.parameters(), lr=learning_rate)
    loss_fcn = nn.CrossEntropyLoss()
    writer = SummaryWriter(log_dir=f"runs/cv_fold{fold}")

    history = train_model(
        model=model,
        optimizer=optimizer,
        train_dataloader=train_loader,
        n_epochs=cv_epochs,
        loss_fcn=loss_fcn,
        device=device,
        val_dataloader=val_loader,
        writer=writer,
    )
    writer.close()

    final = history[-1]
    fold_metrics.append(final)
    print(f"Fold {fold}: train acc {final['train_accuracy']:.3%} | val acc {final['val_accuracy']:.3%}")

# Aggregate metrics with sample statistics (std, SEM, 95% confidence interval).
def agg_stats(values):
    arr = np.asarray(values, dtype=float)
    mean = arr.mean()
    std = arr.std(ddof=1)  # sample std
    sem = std / np.sqrt(len(arr))  # standard error of the mean
    ci95_low = mean - 1.96 * sem
    ci95_high = mean + 1.96 * sem
    return dict(mean=mean, std=std, sem=sem, ci95=(ci95_low, ci95_high))

val_accs = [m["val_accuracy"] for m in fold_metrics]
train_accs = [m["train_accuracy"] for m in fold_metrics]
val_losses = [m["val_loss"] for m in fold_metrics]
train_losses = [m["train_loss"] for m in fold_metrics]

stats = {
    "Train Accuracy": agg_stats(train_accs),
    "Val Accuracy": agg_stats(val_accs),
    "Train Loss": agg_stats(train_losses),
    "Val Loss": agg_stats(val_losses),
}

print("=== Cross-Validation Summary (mean +/- SEM; 95% CI) ===")
for name, s in stats.items():
    m, sd, se = s["mean"], s["std"], s["sem"]
    lo, hi = s["ci95"]
    # %-style for accuracies, plain for losses
    if "Accuracy" in name:
        print(
            f"{name:15s}: {m:7.3%} +/- {se:7.3%}  (std={sd:7.3%}, 95% CI [{lo:7.3%}, {hi:7.3%}])")
    else:
        print(
            f"{name:15s}: {m:8.4f} +/- {se:8.4f}  (std={sd:8.4f}, 95% CI [{lo:8.4f}, {hi:8.4f}])")
CV: 5 folds × 30 epochs | lr=0.003, bs=64

--- Fold 1/5 ---
Fold 1: train acc 25.182% | val acc 2.117%

--- Fold 2/5 ---
Fold 2: train acc 31.913% | val acc 2.326%

--- Fold 3/5 ---
Fold 3: train acc 24.087% | val acc 2.395%

--- Fold 4/5 ---
Fold 4: train acc 32.052% | val acc 1.597%

--- Fold 5/5 ---
Fold 5: train acc 35.002% | val acc 1.944%

=== Cross-Validation Summary (mean ± SEM; 95% CI) ===
Train Accuracy : 29.647% ±  2.127%  (std= 4.755%, 95% CI [25.479%, 33.815%])
Val Accuracy   :  2.076% ±  0.144%  (std= 0.321%, 95% CI [ 1.794%,  2.357%])
Train Loss     :   2.9572 ±   0.1108  (std=  0.2478, 95% CI [  2.7399,   3.1744])
Val Loss       :   7.5298 ±   0.3445  (std=  0.7703, 95% CI [  6.8546,   8.2049])

Ergebnisse

Die zusammengefassten Kennzahlen bestätigen das Bild:

  • Train Acc: 29.65 % ± 2.13 %
  • Val Acc: 2.08 % ± 0.14 %
  • Train Loss: 2.96 ± 0.11
  • Val Loss: 7.53 ± 0.34

Die Validierungsleistung liegt in allen Folds bei rund 2 %, was praktisch Zufallsniveau ist (bei 120 Klassen wäre Zufall ~0.8 %, wir liegen also knapp darüber).

Wichtig ist aber:
- Die Streuung zwischen den Folds ist klein → das Modell ist zwar schlecht, aber immerhin vorhersehbar schlecht.
- Die höhere Streuung im Train Acc zeigt, dass das Modell teilweise einzelne Folds etwas stärker „memorisiert“, aber daraus keine echte Generalisierung entsteht.

FlexCNN, Hypothesen & Experimente

Da wir später viele Varianten testen wollen, brauchen wir ein Modell, das flexibel genug ist, um Dinge wie Anzahl Convs, Kernelgrössen, Dropout oder BatchNorm einfach ein- und ausschalten zu können.

Die FlexCNN-Klassen

FlexCNN

Die FlexCNN-Klasse ist eine erweiterbare Version unseres Baseline CNN.
Wir können hier verschiedene Aspekte über Parameter steuern:
- Anzahl der Convolution-Layer
- Anzahl Filter pro Layer
- Kernel-Grössen
- Pooling-Konfiguration
- Dropout im Head
- Gewichtinitialisierung usw.

Die Idee ist, ein Modell zu haben, das nicht statisch ist, sondern sich über Parameter so anpassen lässt, dass wir jede Hypothese als klar definierte Variante abbilden können – ohne ständig eine neue Architektur programmieren zu müssen.

FlexCNN_BN

Die Klasse FlexCNN_BN erweitert das Ganze um BatchNorm.
Statt die gesamte Architektur umzubauen, aktivieren wir BatchNorm einfach über diese Variante. Das macht es möglich, BatchNorm isoliert zu testen, ohne alle anderen Komponenten zu verändern.

Damit haben wir zwei parallele Varianten: - FlexCNN → ohne BatchNorm
- FlexCNN_BN → identisch, aber mit BatchNorm layern

Classifier Head „lazy“ bauen

Ein wichtiger Punkt im Code ist, dass wir den FC-Head lazy bestimmen – also erst dann bauen, wenn wir wissen, wie gross das Feature-Embedding nach den Convolution-Blöcken tatsächlich ist.

Warum das nötig ist: - Je nach Anzahl Layer, Kernelgrössen, Padding und Pooling verändert sich die räumliche Grösse der Feature Maps.
- Das bedeutet: die Dimension der Flatten-Ausgabe ist nicht fix.
- Würden wir den Head vorher definieren, müssten wir für jede Architektur manuell nachrechnen, wie viele Features am Ende rauskommen – und das wäre extrem fehleranfällig.

Durch den lazy-Build: 1. Wir schicken einmal einen Dummy-Tensor durch das CNN.
2. Messen die Output-Dimension.
3. Bauen danach automatisch ein passendes Fully-Connected-Head auf.

Das macht die ganze Architektur-Variation erst praktisch möglich.

class FlexCNN(nn.Module):
    """
    A flexible Convolutional Neural Network with a dynamically created classifier.

    This CNN's architecture is defined by the provided hyperparameters,
    allowing for a variable number of convolutional layers. The classifier
    (fully connected layers) is constructed during the first forward pass
    to adapt to the output size of the convolutional feature extractor.
    """

    def __init__(
        self,
        n_layers,
        n_filters,
        kernel_sizes,
        dropout_rate,
        fc_size,
        pool_schedule=None,
    ):
        """
        Initialize the feature extraction portion of the network.

        Args:
            n_layers (int): Number of convolutional blocks to build.
            n_filters (list[int]): Output channels for each block.
            kernel_sizes (list[int]): Kernel sizes for the convolutions.
            dropout_rate (float): Dropout probability used in the classifier.
            fc_size (int): Hidden size of the fully connected layer.
            pool_schedule (list[bool] | None): Optional flags that control
                whether each block applies a MaxPool layer. Defaults to pooling
                after every block for backwards compatibility.

        Returns:
            None
        """
        super(FlexCNN, self).__init__()

        if pool_schedule is None:
            pool_schedule = [True] * n_layers
        if not (len(n_filters) == len(kernel_sizes) == len(pool_schedule) == n_layers):
            raise ValueError(
                "n_layers must match the length of n_filters, kernel_sizes, and pool_schedule"
            )

        blocks = []
        in_channels = 3

        for i in range(n_layers):
            out_channels = n_filters[i]
            kernel_size = kernel_sizes[i]
            padding = (kernel_size - 1) // 2
            use_pool = pool_schedule[i]

            # Each block is Conv -> ReLU with optional pooling for downsampling.
            layers = [
                nn.Conv2d(in_channels, out_channels, kernel_size, padding=padding),
                nn.ReLU(),
            ]
            if use_pool:
                layers.append(nn.MaxPool2d(kernel_size=2, stride=2))

            block = nn.Sequential(*layers)
            blocks.append(block)
            in_channels = out_channels  # next block consumes prior block's channels

        # Feature extractor is a Sequential container composed of the configured blocks.
        self.features = nn.Sequential(*blocks)
        self.dropout_rate = dropout_rate
        self.fc_size = fc_size
        self.classifier = None

    def _create_classifier(self, flattened_size, device):
        # Build the classifier lazily to match whatever spatial resolution remains.
        self.classifier = nn.Sequential(
            nn.Dropout(self.dropout_rate),
            nn.Linear(flattened_size, self.fc_size),
            nn.ReLU(inplace=True),
            nn.Dropout(self.dropout_rate),
            nn.Linear(self.fc_size, 120),
        ).to(device)

    def forward(self, x):
        device = x.device
        x = self.features(x)
        flattened = torch.flatten(x, 1)
        flattened_size = flattened.size(1)
        if self.classifier is None:
            self._create_classifier(flattened_size, device)
        return self.classifier(flattened)
# Optional weight initializers that can be applied to a model prior to training.
def he_init(m):
    if isinstance(m, (nn.Conv2d, nn.Linear)):
        nn.init.kaiming_normal_(m.weight, nonlinearity="relu")
        if m.bias is not None:
            nn.init.zeros_(m.bias)

def xavier_init(m):
    if isinstance(m, (nn.Conv2d, nn.Linear)):
        nn.init.xavier_normal_(m.weight)
        if m.bias is not None:
            nn.init.zeros_(m.bias)

# BatchNorm variant
# (Same signature & idea as FlexCNN; only adds BatchNorm2d after each Conv2d)
class FlexCNN_BN(nn.Module):
    def __init__(self, n_layers, n_filters, kernel_sizes, dropout_rate, fc_size, pool_schedule=None):
        super().__init__()
        if pool_schedule is None:
            pool_schedule = [True] * n_layers
        assert len(n_filters) == len(kernel_sizes) == len(pool_schedule) == n_layers, \
            "n_layers must match lengths of n_filters, kernel_sizes, pool_schedule"

        blocks, in_ch = [], 3
        for i in range(n_layers):
            out_ch = n_filters[i]
            k = kernel_sizes[i]
            pad = (k - 1) // 2
            use_pool = pool_schedule[i]
            layers = [
                nn.Conv2d(in_ch, out_ch, k, padding=pad),
                nn.BatchNorm2d(out_ch),
                nn.ReLU(inplace=True),
            ]
            if use_pool:
                layers.append(nn.MaxPool2d(2, 2))
            blocks.append(nn.Sequential(*layers))
            in_ch = out_ch

        self.features = nn.Sequential(*blocks)
        self.dropout_rate = dropout_rate
        self.fc_size = fc_size
        self.classifier = None  # built lazily to match flattened size

    def _create_classifier(self, flat_size, device):
        self.classifier = nn.Sequential(
            nn.Dropout(self.dropout_rate),
            nn.Linear(flat_size, self.fc_size),
            nn.ReLU(inplace=True),
            nn.Dropout(self.dropout_rate),
            nn.Linear(self.fc_size, 120),  # dataset has 120 classes in this notebook
        ).to(device)

    def forward(self, x):
        x = self.features(x)
        flat = torch.flatten(x, 1)
        if self.classifier is None:
            self._create_classifier(flat.size(1), x.device)
        return self.classifier(flat)

Die run_flex_experiment-Funktion

Damit wir jede Hypothese wirklich sauber und vergleichbar testen können, gibt es die Funktion run_flex_experiment.
Sie übernimmt:

  • Instanziieren des Modells (FlexCNN oder FlexCNN_BN)
  • Training für definierte Epochen
  • Logging von Loss & Accuracy
  • Speichern der Kurven
  • Rückgabe der finalen Ergebnisse
  • einen konsistenten Namensraum pro Experiment (run_name)

Damit ist garantiert, dass alle Experimente unter denselben Bedingungen ablaufen.
Der Unterschied kommt einzig aus den Änderungen, die wir für die jeweilige Hypothese einbauen.

# Minimal runner that reuses the notebook's training loop & loaders
def run_flex_experiment(
    run_name,
    n_layers, n_filters, kernel_sizes, pool_schedule,
    dropout_rate=0.0, fc_size=256,
    model_class=None,                 # FlexCNN or FlexCNN_BN
    optimizer_name="SGD",
    lr=None,
    epochs=None,
    dataloader=None,                  # trainloader or trainloader_with_aug
    val_loader=None,                  # validationloader
    weight_init=None,                 # he_init, xavier_init, or None
):
    """
    Creates the model, optionally applies weight init, trains with the notebook's train_model,
    logs to TensorBoard, and plots curves with helper_utils.plot_learning_curves.
    """
    # Defaults from the notebook's globals
    if model_class is None:
        model_class = FlexCNN  # defined earlier in the notebook
    if lr is None:
        lr = learning_rate
    if epochs is None:
        epochs = baseline_epochs
    if dataloader is None:
        dataloader = trainloader
    if val_loader is None:
        val_loader = validationloader

    log_dir = os.path.join("runs", run_name)
    if os.path.isdir(log_dir):
        shutil.rmtree(log_dir)

    model = model_class(
        n_layers=n_layers,
        n_filters=n_filters,
        kernel_sizes=kernel_sizes,
        dropout_rate=dropout_rate,
        fc_size=fc_size,
        pool_schedule=pool_schedule,
    ).to(device)

    # Build classifier head lazily (as in your notebook)
    _ = model(torch.zeros(1, 3, 128, 128, device=device))

    if weight_init is not None:
        model.apply(weight_init)

    if optimizer_name.lower() == "adam":
        optimizer = optim.Adam(model.parameters(), lr=lr)
    else:
        optimizer = optim.SGD(model.parameters(), lr=lr)

    writer = SummaryWriter(log_dir=log_dir)
    history = train_model(
        model=model,
        optimizer=optimizer,
        train_dataloader=dataloader,
        n_epochs=epochs,
        loss_fcn=nn.CrossEntropyLoss(),
        device=device,
        val_dataloader=val_loader,
        writer=writer,
    )
    writer.close()

    final = history[-1]
    print(f"{run_name}: train acc {final['train_accuracy']:.3%} | val acc {final['val_accuracy']:.3%}")
    helper_utils.plot_learning_curves(run_name)
    return history

Übersicht der Hypothesen H1 – H12

Insgesamt testen wir zwölf Hypothesen rund um Modellarchitektur, Regularisierung und Trainingsdynamik:

Regularisierung / Stabilisierung

  • H1: Data Augmentation verbessert die Generalisierung.
  • H2: Dropout im FC-Head reduziert Overfitting.
  • H3: BatchNorm stabilisiert das Training und führt zu besserer Val-Accuracy.

Initialisierung

  • H4: He-Initialisierung führt zu schnellerer und stabilerer Konvergenz (v. a. bei ReLU).

Modellkomplexität

  • H5: Mehr Convolution-Layer (tieferes Modell) → bessere Feature-Extraktion.
  • H6: Weniger Convolution-Layer (flacheres Modell) → schlechtere Performance.
  • H7: Moderat mehr Filter → bessere Repräsentationsfähigkeit.
  • H8: Viel mehr Filter → schwächere Performance.

Convolution-Parameter

  • H9: Grössere Kernelgrössen (z. B. 5×5) verbessern die Erkennung lokaler Strukturen.
  • H10: Angepasstes Pooling (weniger Pool-Schritte) beeinflusst die Feature-Hierarchie.

Optimizer / Lernrate

  • H11: Adam konvergiert schneller als SGD und liefert bessere Ergebnisse.
  • H12: Höhere LR funktioniert besser, wenn BatchNorm aktiv ist.

Die Experimente

# Keep a growing list of all runs for a final summary and import the helper to report best checkpoints.
all_runs = []
from helper_utils import report_best

H1 – Data Augmentation verbessert die Generalisierung

Hypothese:
Wenn wir das Trainset mit leichter Augmentation (Flips, kleine Rotationen) variieren, sollte das Modell robuster werden und weniger schnell überfitten.

Referenz: A survey on Image Data Augmentation for Deep Learning (2019), https://journalofbigdata.springeropen.com/articles/10.1186/s40537-019-0197-0

Experiment:
Wir aktivieren im Training RandomHorizontalFlip und RandomRotation, alle anderen Settings (Architektur, lr=0.003, bs=64, Epochenzahl) bleiben gleich.

# H1: Augmentation only (no BN, no dropout), 3 blocks
name = "exp2/h01_aug_only"
h1 = run_flex_experiment(
    name,
    n_layers=3,
    n_filters=[32, 64, 128],
    kernel_sizes=[3, 3, 3],
    pool_schedule=[True, True, True],
    dropout_rate=0.0,
    fc_size=256,
    dataloader=trainloader_with_aug,
    lr=learning_rate,
    epochs=100,
)
report_best(name, h1)
all_runs.append((name, h1))  # Track results for consolidated summary
exp2/h01_aug_only: train acc 79.064% | val acc 9.135%

exp2/h01_aug_only: best@epoch 100 | val_acc=9.135% | train_acc=79.064% | val_loss=7.3772 | train_loss=0.7610

Ergebnis:
Die Lernkurven zeigen ein gemischtes Bild:

  • Der Train Loss sinkt kontinuierlich und die Train Accuracy steigt bis auf rund 79 % an.
  • Die Val Accuracy bleibt dagegen über fast alle 100 Epochen hinweg sehr tief (um die 5–9 %) und steigt nur minimal.
  • Der Val Loss wird mit zunehmender Epoche sogar höher und deutlich wackeliger.

Im Vergleich zur ursprünglichen Baseline (Val Acc ~2 %) bekommen wir zwar eine leichte Verbesserung, aber von „guter Generalisierung“ sind wir immer noch weit entfernt. Das Modell overfittet weiterhin stark: Es lernt das Trainset deutlich besser, kann dieses Wissen aber kaum auf das Val-Set übertragen.

Reflexion:
Die Hypothese wird nur teilweise bestätigt.
Data Augmentation allein reicht in unserem Setup nicht aus, um das Overfitting wirklich in den Griff zu bekommen. Sie bringt eine kleine Verbesserung gegenüber der nackten Baseline, aber die Architektur und das Trainingssetup sind insgesamt noch zu schwach, um die vielen Hunderassen sinnvoll zu trennen.

H2 – Dropout im FC-Head reduziert Overfitting

Hypothese:
Durch Dropout im Classifier Head sollte das Modell weniger stark überfitten, da es gezwungen wird, robusterere Repräsentationen zu lernen. Wir erwarten, dass die Train Accuracy tiefer bleibt, während die Validation Accuracy stabiler oder leicht besser wird.

Referenz: Dropout: A Simple Way to Prevent Neural Networks from Overfitting, https://jmlr.org/papers/v15/srivastava14a.html

Experiment:
Wir aktivieren Dropout (p=0.5) im Fully-Connected-Head.
Alle anderen Settings bleiben identisch zu H1: gleiche Architektur, gleiche Augmentation, lr=0.003, bs=64, 100 Epochen.

# H2: +Dropout on top of augmentation (still no BN), 3 blocks
name = "exp2/h02_aug_dropout"
h2 = run_flex_experiment(
    name,
    n_layers=3,
    n_filters=[32, 64, 128],
    kernel_sizes=[3, 3, 3],
    pool_schedule=[True, True, True],
    dropout_rate=0.5,
    fc_size=256,
    dataloader=trainloader_with_aug,
    lr=learning_rate,
    epochs=100,
)
report_best(name, h2)
all_runs.append((name, h2))  # Track results for consolidated summary
exp2/h02_aug_dropout: train acc 16.528% | val acc 11.014%

exp2/h02_aug_dropout: best@epoch 100 | val_acc=11.014% | train_acc=16.528% | val_loss=3.9853 | train_loss=3.5566

Ergebnis:
Die Kurven zeigen ein Verhalten, das ziemlich klar auf den Dropout-Effekt hinweist:

  • Train Accuracy steigt nur moderat auf ca. 16 % (H1 lag bei ~79 %).
    → Dropout erschwert das reine Memorieren stark, wie gewünscht.
  • Validation Accuracy erreicht ca. 11 % und liegt damit deutlich höher als bei H1 (ca. 9 %).
    Gleichzeitig bleibt sie über die Epochen hinweg relativ stabil.
  • Train Loss sinkt langsamer, aber konstant.
  • Val Loss bleibt flach und zeigt deutlich weniger wackelige Ausreisser als bei H1.

Die zentrale Beobachtung:
Die Val Accuracy von ~11 % ist bis jetzt die beste, die wir mit der reinen CNN-Architektur erreicht haben.

Reflexion:
Die Hypothese wird deutlich bestätigt.

Dropout reduziert das Overfitting massiv (Train Acc von 79 % → 16 %) und verbessert gleichzeitig die Validation Performance klar (von ~9 % auf ~11 %).
Das Modell lernt weniger „auswendig“ und dafür etwas mehr generalisierbare Strukturen.

H3 – BatchNorm stabilisiert das Training und verbessert die Generalisierung

Hypothese:
BatchNorm sollte das Training stabilisieren, die Gradientendynamik verbessern und insgesamt zu besserer Validation Accuracy führen – besonders in Kombination mit Augmentation und Dropout.

Referenz: Accelerating Deep Network Training by Reducing Internal Covariate Shift, https://arxiv.org/pdf/1502.03167

Experiment:
Wir verwenden die FlexCNN_BN-Variante, also dieselbe Architektur wie in H2, aber zusätzlich mit BatchNorm-Layern in allen Convolution-Blöcken.
Alle anderen Parameter bleiben konstant: Augmentation aktiv, Dropout aktiv, lr=0.003, bs=64, 100 Epochen.

# H3: +BatchNorm (stabilized recipe: aug + dropout + BN), 3 blocks
name = "exp2/h03_aug_dropout_bn"
h3 = run_flex_experiment(
    name,
    n_layers=3,
    n_filters=[32, 64, 128],
    kernel_sizes=[3, 3, 3],
    pool_schedule=[True, True, True],
    dropout_rate=0.5,
    fc_size=256,
    model_class=FlexCNN_BN,
    dataloader=trainloader_with_aug,
    lr=0.02,
    epochs=100,
)
report_best(name, h3)
all_runs.append((name, h3))  # Track results for consolidated summary
exp2/h03_aug_dropout_bn: train acc 21.304% | val acc 15.193%

exp2/h03_aug_dropout_bn: best@epoch 97 | val_acc=17.687% | train_acc=19.811% | val_loss=3.4156 | train_loss=3.2025

Ergebnis:
Die Lernkurven zeigen eine klare Verbesserung gegenüber H1 und H2:

  • Train Accuracy steigt auf ~21 %, damit höher als in H2 (ca. 16 %).
  • Validation Accuracy erreicht ~15 % (Peak sogar ~17.6 %), damit die bis jetzt beste Validation Performance unter allen reinen CNN-Modellen.
  • Der Train Loss sinkt zuverlässig und ohne Instabilitäten.
  • Der Val Loss ist zwar etwas zackig (was bei kleinen Val-Batches normal ist), aber der Trend ist klar besser als bei H1 und H2.

Verglichen mit H2 (Val Acc ~11 %) liefert BatchNorm also einen deutlichen Boost:
+4–6 Prozentpunkte mehr Validation Accuracy, obwohl das Modell weiterhin genau dieselbe Architektur hat.

Reflexion:
Die Hypothese wird voll bestätigt.

BatchNorm wirkt in unserem Setup stark regulierend und stabilisierend.
Es verhindert weder Overfitting komplett noch zaubert es sofort hohe Accuracy herbei, aber es schafft – zusammen mit Augmentation und Dropout – erstmals eine Val Accuracy über 15 %, also einen deutlichen Sprung gegenüber den früheren Varianten.

H4 – He-Initialisierung verbessert die Konvergenz (bei ReLU)

Hypothese:
Bei ReLU-basierten Netzwerken sollte He-Initialisierung (statt der PyTorch-Default-Init) dafür sorgen, dass die Aktivierungen besser im „gesunden Bereich“ bleiben.
Wir erwarten eigentlich:
- schnellere Konvergenz
- stabilere Lernkurven
- leicht höhere Accuracy

Referenz: Delving Deep into Rectifiers: Surpassing Human-Level Performance on ImageNet Classification, https://arxiv.org/abs/1502.01852

Experiment:
Wir aktivieren He-Initialisierung für alle Convolution-Layer.
Die restliche Architektur entspricht der Variante aus H3:
BatchNorm + Dropout + Augmentation + lr=0.003 + bs=64, 100 Epochen.

# H4: He initialization under stabilized recipe, 3 blocks
name = "exp2/h04_he_init_bn_aug_do"
h4 = run_flex_experiment(
    name,
    n_layers=3,
    n_filters=[32, 64, 128],
    kernel_sizes=[3, 3, 3],
    pool_schedule=[True, True, True],
    dropout_rate=0.5,
    fc_size=256,
    model_class=FlexCNN_BN,
    dataloader=trainloader_with_aug,
    lr=0.02,
    epochs=100,
    weight_init=he_init,
)
report_best(name, h4)
all_runs.append((name, h4))  # Track results for consolidated summary
exp2/h04_he_init_bn_aug_do: train acc 2.249% | val acc 2.883%

exp2/h04_he_init_bn_aug_do: best@epoch 100 | val_acc=2.883% | train_acc=2.249% | val_loss=4.5288 | train_loss=4.6202

Ergebnis:
Die tatsächlichen Kurven zeigen ein ganz anderes Bild als die Theorie erwarten lässt:

  • Train Accuracy bleibt extrem niedrig (ca. 2.2 %)
  • Val Accuracy bleibt ebenfalls sehr niedrig (ca. 2.8 %)
  • Es gibt keine nennenswerte Verbesserung gegenüber der ursprünglichen Baseline
  • Der Train Loss sinkt kaum und bleibt ungewöhnlich hoch
  • Der Val Loss zeigt zwar etwas Bewegung, aber ohne klaren Trend nach unten

Insgesamt wirkt das Modell, als hätte es grosse Schwierigkeiten, aus den Daten überhaupt sinnvolle Features zu extrahieren.

Reflexion:
Die Hypothese wird klar widerlegt.

He-Initialisierung bringt in unserem Setup keinen Vorteil – im Gegenteil, sie führt zu einem massiven Leistungsabfall gegenüber H3. Während wir mit BatchNorm + Dropout zuvor Val Accuracy im Bereich von 11–17 % erreicht hatten, fällt H4 wieder auf fast Baseline-Niveau zurück (um 2–3 %).

Ein besonders plausibler Grund dafür liegt in der Reihenfolge der Initialisierung innerhalb unserer Experiment-Hilfsfunktion:

  • Um den Classifier-Head “lazy” zu bauen, schicken wir zuerst einen Dummy-Forward-Pass durch das Modell.
  • Dieser Forward-Pass läuft bereits durch die BatchNorm-Layer und aktualisiert deren running_mean und running_var – allerdings mit komplett künstlichen Daten.
  • Erst danach wendet unser Code die He-Initialisierung an und überschreibt sämtliche Gewichte.

Dadurch entsteht ein Mismatch zwischen frisch initialisierten Convolution-Gewichten und völlig unpassenden BN-Statistiken, die auf synthetischem Dummy-Input basieren.
Das Modell startet dann mit:

  • fehlerhaften BN-Runtime-Statistiken
  • frischen He-Initialisierten Gewichten
  • Dropout + Augmentation oben drauf

Diese Kombination macht die ersten Epochen unnötig instabil, und wir sehen genau dieses Verhalten:
langsame Konvergenz, extrem niedrige Accuracy und kaum sinkender Loss.

Dazu kommen die üblichen Einflussfaktoren:

  1. BatchNorm neutralisiert einen grossen Teil der Vorteile von He-Init.
  2. Dropout erschwert frühe Anpassungen zusätzlich.
  3. Unser Modell ist relativ klein, wodurch Initialisierungseffekte stärker spürbar sind.
  4. Die gewählte Learning Rate (0.003) passt eventuell nicht gut zur so entstandenen Startkonfiguration.

In Summe passt He-Init in dieser konkreten Setup-Kombination nicht gut, vor allem wegen der ungünstigen Reihenfolge von Dummy-Forward → BN-Update → He-Init.

H5 – Mehr Convolution-Layer (tieferes Modell) verbessert die Feature-Extraktion

Hypothese:
Ein tieferes CNN (mehr Conv-Blöcke) sollte komplexere Features erfassen können.
Wir erwarten: - deutlich bessere Training Accuracy
- bessere Validation Accuracy
- insgesamt höhere Kapazität, aber auch etwas mehr Overfitting

Referenz: Very Deep Convolutional Networks for Large-Scale Image Recognition (VGG), https://arxiv.org/abs/1409.1556

Experiment:
Wir erhöhen die Modell-Tiefe auf 5 Convolution-Blöcke.
BatchNorm + Dropout + Augmentation bleiben aktiv, lr=0.003, bs=64, 100 Epochen.

# H5: Depth ↑ to 5 blocks (pool every block → 4×4), stabilized recipe
name = "exp2/h05_depth5_bn_aug_do"
h5 = run_flex_experiment(
    name,
    n_layers=5,
    n_filters=[32, 64, 128, 128, 128],
    kernel_sizes=[3, 3, 3, 3, 3],
    pool_schedule=[True, True, True, True, True],
    dropout_rate=0.5,
    fc_size=256,
    model_class=FlexCNN_BN,
    dataloader=trainloader_with_aug,
    lr=0.02,
    epochs=100,
)
report_best(name, h5)
all_runs.append((name, h5))  # Track results for consolidated summary
exp2/h05_depth5_bn_aug_do: train acc 46.099% | val acc 16.359%

exp2/h05_depth5_bn_aug_do: best@epoch 89 | val_acc=35.439% | train_acc=43.239% | val_loss=2.5250 | train_loss=2.0013

Ergebnis:
Die Resultate zeigen einen massiven Sprung:

  • Train Accuracy steigt auf ~46 % (vs. ~20 % bei H3)
  • Validation Accuracy erreicht ~16 % (Peak sogar ~35.4 % während des Trainings)
  • Train Loss sinkt sauber und stetig
  • Val Loss ist unruhig, aber mit klarer Tendenz nach unten

Die Unruhe der Validation-Kurve ist typisch für: - höhere Modellkapazität +
- starke BatchNorm-Dynamik +
- schwere Klasse-zu-Klasse-Unterscheidung (120 Klassen).

Das Modell schwankt also viel – aber auf einem deutlich höheren Leistungsniveau.

Reflexion:
Die Hypothese wird mehr als bestätigt.

Mehr Tiefe bringt einen klaren, deutlichen Performance-Boost.
Die Architektur kann deutlich aussagekräftigere Features extrahieren, und die Resultate zeigen das sehr direkt:

  • Das Modell ist viel lernfähiger
  • Es erreicht deutlich bessere Training Accuracy
  • Die Validation Accuracy steigt auf ein neues Niveau

H6 – Zu tief (8 Convolution-Blöcke): Mehr Tiefe verschlechtert die Optimierbarkeit

Hypothese:
Wenn wir das Modell von 5 auf 8 Conv-Blöcke erweitern, wird die Optimierung schwieriger.
Wir erwarten, dass die zusätzliche Tiefe nicht mehr zu besserer Performance führt, sondern dass die Validation Accuracy stagniert oder sogar schlechter wird – trotz BatchNorm, Augmentation und Dropout.

Referenz: Deep Residual Learning for Image Recognition, https://arxiv.org/abs/1512.03385

Experiment:
Wir erhöhen die Netzwerk-Tiefe auf 8 Conv-Blöcke, ansonsten identisch zu H5: BatchNorm + Dropout + Augmentation, lr=0.003, bs=64, 100 Epochen.

# H6: Too deep: 8 blocks (5 pools → final 4×4), stabilized recipe
name = "exp2/h06_depth8_bn_aug_do"
h6 = run_flex_experiment(
    name,
    n_layers=8,
    n_filters=[32, 64, 128, 128, 128, 128, 128, 128],
    kernel_sizes=[3]*8,
    pool_schedule=[True, True, True, True, True, False, False, False],
    dropout_rate=0.5,
    fc_size=256,
    model_class=FlexCNN_BN,
    dataloader=trainloader_with_aug,
    lr=0.02,
    epochs=100,
)
report_best(name, h6)
all_runs.append((name, h6))  # Track results for consolidated summary
exp2/h06_depth8_bn_aug_do: train acc 74.198% | val acc 22.546%

exp2/h06_depth8_bn_aug_do: best@epoch 98 | val_acc=28.215% | train_acc=72.997% | val_loss=4.1407 | train_loss=0.8605

Ergebnis:
Die Resultate passen erstaunlich gut zur Hypothese:

  • Train Accuracy steigt bis auf ~74 % – das Modell kann also memorieren.

  • Validation Accuracy bleibt jedoch deutlich tiefer als in H5:
    Rund 22.5 % (mit Peaks bis ca. 28 %).
    → H5 erreichte Val-Peaks über 35 %.

  • Die Val-Kurve bleibt extrem unruhig, mit plötzlichen starken Loss-Spikes.

  • Der Train Loss sinkt sauber (wie in einem grossen Modell zu erwarten).

  • Der Val Loss bleibt hoch und chaotisch, ohne sichtbare Verbesserung gegenüber H5.

Das Modell ist also zwar gross genug, um das Training stark zu fitten – aber nicht mehr in der Lage, dieses Wissen sinnvoll zu generalisieren.

Reflexion:
Die Hypothese wird bestätigt:

  • 5 Blöcke → bestes Gleichgewicht aus Kapazität & Optimierbarkeit
  • 8 Blöcke → das Modell wird zu tief für unseren Trainings-„Recipe“
    (Learning Rate, Regularisierung, Datenmenge, BatchNorm-Statistiken, etc.)

Wir sehen ein klassisches Muster eines zu tiefen CNNs ohne ResNet-Skip-Verbindungen

H7 – Mehr Breite (moderate width) verbessert die Repräsentationsfähigkeit

Hypothese:
Wenn wir die Anzahl Filter pro Layer moderat erhöhen, sollte das Modell mehr unterschiedliche Features extrahieren können.
Wir erwarten idealerweise: - höhere Train Accuracy
- bessere Validation Accuracy
- insgesamt stabilere Lernfähigkeit

Referenz: Wide Residual Networks, https://arxiv.org/abs/1605.07146

Experiment:
Wir erhöhen die Filterzahlen pro Conv-Block (moderate width),
lassen ansonsten aber das „erfolgreiche Rezept“ unverändert:
BatchNorm + Dropout + Augmentation, lr=0.003, bs=64, 100 Epochen.

# H7: Width ↑ moderately to [64,128,256], stabilized recipe (3 blocks)
name = "exp2/h07_width_moderate_bn_aug_do"
h7 = run_flex_experiment(
    name,
    n_layers=3,
    n_filters=[64, 128, 256],
    kernel_sizes=[3, 3, 3],
    pool_schedule=[True, True, True],
    dropout_rate=0.5,
    fc_size=256,
    model_class=FlexCNN_BN,
    dataloader=trainloader_with_aug,
    lr=0.02,
    epochs=100,
)
report_best(name, h7)
all_runs.append((name, h7))  # Track results for consolidated summary
exp2/h07_width_moderate_bn_aug_do: train acc 4.533% | val acc 5.021%

exp2/h07_width_moderate_bn_aug_do: best@epoch 99 | val_acc=5.475% | train_acc=4.547% | val_loss=4.2109 | train_loss=4.2535

Ergebnis:
Die Resultate fallen überraschend schwach aus:

  • Train Accuracy bleibt extrem tief (~4.5 %), also schlechter als jede tiefe Variante (H5, H6).
  • Validation Accuracy bleibt ebenfalls sehr tief (~5 %), kaum über Zufallsniveau.
  • Train Loss sinkt nur minimal und sehr langsam.
  • Val Loss bleibt hoch und schwankt deutlich.

Kurz gesagt:
Das Modell lernt fast gar nichts – weder memorisieren (Train Acc ↑) noch generalisieren (Val Acc ↑).

Warum performt „mehr Breite“ hier schlechter als erwartet?

Ein paar plausible Ursachen:

  1. Breite ohne ausreichend Tiefe hilft nicht
    CNNs profitieren von Hierarchie, nicht nur von breiten Layern.
    Mit zu wenig Tiefenstruktur bringt die zusätzliche Kanal-Kapazität kaum etwas.

  2. BatchNorm + breite Channels = mehr zu stabilisierende Aktivierungen
    BN muss pro Channel Statistiken lernen.
    Mehr Channels = mehr Rauschen, mehr Varianz am Anfang, instabiler Start.

  3. Mehr Parameter = schwierigerer Optimizer-Start
    Bei lr=0.003 kann ein breites Modell zu Beginn schnell in suboptimale Richtungen rutschen.
    Das erklärt die extrem niedrige Train Acc: Das Modell findet gar nicht erst einen brauchbaren Pfad.

  4. Moderate Width ≠ Good Width
    Es ist möglich, dass deine gewählte Breitenkonfiguration in einem „ungünstigen Zwischenraum“ liegt:
    Nicht breit genug für echten Kapazitätsgewinn, aber breit genug, um die BN/Optimizer-Dynamik zu stören.

  5. Das Modell ist jetzt eher „flach + breit“ statt „tiefer + schmal“
    Aus H5 wissen wir: Tiefe hilft massiv.
    Mehr Breite ohne mehr Tiefe hilft hier offensichtlich nicht.

Reflexion:
Die Hypothese wird klar widerlegt.

Mehr Breite führt in unserem Setup nicht zu besserer Repräsentationsfähigkeit –
im Gegenteil, es verschlechtert sowohl Training als auch Validation dramatisch.

H8 – Zuviel Breite

Hypothese:
Wenn wir die Breite stark erhöhen ([128, 256, 512]), steigt vor allem die Train Accuracy und das Modell wird deutlich grösser. Für die Validation erwarten wir höchstens kleine Gewinne bzw. schnell abnehmenden Nutzen (diminishing returns), selbst mit Regularisierung.

Referenz: Deep Double Descent: Where Bigger Models and More Data Hurt, https://arxiv.org/abs/1912.02292

Experiment:
Wir verwenden ein 3-Block-CNN mit sehr breiten Layern, BatchNorm, Dropout=0.5, Augmentation, lr=0.02 und trainieren 100 Epochen.

# H8: Width ↑ large to [128,256,512], stabilized recipe (3 blocks)
name = "exp2/h08_width_large_bn_aug_do"
h8 = run_flex_experiment(
    name,
    n_layers=3,
    n_filters=[128, 256, 512],
    kernel_sizes=[3, 3, 3],
    pool_schedule=[True, True, True],
    dropout_rate=0.5,
    fc_size=256,
    model_class=FlexCNN_BN,
    dataloader=trainloader_with_aug,
    lr=0.02,
    epochs=100,
)
report_best(name, h8)
all_runs.append((name, h8))  # Track results for consolidated summary
exp2/h08_width_large_bn_aug_do: train acc 1.749% | val acc 2.592%

exp2/h08_width_large_bn_aug_do: best@epoch 93 | val_acc=2.786% | train_acc=1.687% | val_loss=4.6230 | train_loss=4.6708

Ergebnis & Reflexion:
Statt hoher Train Accuracy sehen wir praktisch gar keinen Lernfortschritt: Train Acc bleibt um 1–2 %, Val Acc nur bei 2–3 %, der Loss bewegt sich kaum. Die Hypothese wird damit klar widerlegt – das Modell wird zwar riesig, aber nicht besser, im Gegenteil: in dieser Konfiguration ist es offensichtlich kaum optimierbar. Die Kombination „extrem breit + relativ hohe LR“ torpediert das Training komplett; wir bekommen weder mehr Train- noch Val-Performance, nur mehr Parameter.

Der extreme Val-Loss-Spike um Epoch ~75 entsteht aus einer Kombination von sehr breitem Modell, hoher Learning Rate, schlecht kalibrierten BatchNorm-Statistiken und insgesamt instabiler Optimierung. Schon ein einziger Batch mit falsch normalisierten Aktivierungen kann im 120-Klassen-Setting zu einer Reihe sehr konfidenter Fehlklassifikationen führen – und Cross-Entropy reagiert darauf mit einem massiven Loss-Peak.

H9 – Kernel size 5×5 statt 3×3

Hypothese:
Wenn wir alle 3×3-Kernel durch 5×5 ersetzen, erwarten wir keine Verbesserung.
Die 5×5-Kernels haben mehr Parameter, reduzieren die Anzahl der Nichtlinearitäten pro Receptive Field und bieten damit meist keinen Vorteil gegenüber gut gestapelten 3×3-Kernen.
Mit dem stabilisierten Recipe (BN + Dropout + Augmentation) sollte der Effekt höchstens neutral oder leicht negativ sein.

Referenz: Very Deep Convolutional Networks for Large-Scale Image Recognition, https://arxiv.org/abs/1409.1556

Experiment:
Wir übernehmen die Architektur aus H5 (3 Blöcke, moderate Breite, BN, Dropout=0.5, Augmentation), ersetzen aber alle Convs durch 5×5-Kernels.
Training: 100 Epochen, lr=0.02.

# H9: Kernel size 5×5 (vs 3×3), stabilized recipe (3 blocks)
name = "exp2/h09_kernel_5x5_bn_aug_do"
h9 = run_flex_experiment(
    name,
    n_layers=3,
    n_filters=[32, 64, 128],
    kernel_sizes=[5, 5, 5],
    pool_schedule=[True, True, True],
    dropout_rate=0.5,
    fc_size=256,
    model_class=FlexCNN_BN,
    dataloader=trainloader_with_aug,
    lr=0.02,
    epochs=100,
)
report_best(name, h9)
all_runs.append((name, h9))  # Track results for consolidated summary
exp2/h09_kernel_5x5_bn_aug_do: train acc 28.079% | val acc 17.201%

exp2/h09_kernel_5x5_bn_aug_do: best@epoch 88 | val_acc=21.315% | train_acc=22.942% | val_loss=3.2321 | train_loss=3.0698

Ergebnis:
- Val Accuracy: ~21 % (klar schlechter als H5/H6, welche ~35 % erreichten)
- Train Accuracy: ~23 % (niedriger als bei 3×3)
- Val Loss: höher und unruhiger

Reflexion:
Die Hypothese wird bestätigt.
5×5-Kernels liefern keinen Vorteil – im Gegenteil, sie sind in dieser Setup-Klasse klar schlechter als die 3×3-Versionen (H5/H6).

Wahrscheinliche Gründe:

  • Mehr Parameter, aber keine zusätzliche Nichtlinearität
  • Grössere Kernel machen frühe Features „schwammiger“
  • Optimierung wird mit lr=0.02 schwieriger, da jeder Layer deutlich mehr Gewichte aktualisiert

H10 – Light pooling (weniger räumliche Reduktion)

Hypothese:
Wenn wir das Pooling „leichter“ machen (d. h. nicht nach jedem Block, sodass die Feature-Maps grösser bleiben, z. B. ~16×16 statt ~4×4), könnte das Modell theoretisch mehr räumliche Details behalten.
Wir erwarten jedoch keinen echten Vorteil in der Validation Accuracy, da der grössere Classifier mehr Parameter hat und eher zum Overfitting führt – während die zusätzlichen räumlichen Details dem Modell kaum helfen sollten.

Referenz: Network in Network, https://arxiv.org/abs/1312.4400

Experiment:
Wir starten vom starken H5-Setup (5 Blöcke, BN, Dropout, Augmentation, lr=0.02), ändern aber das Pooling-Schema auf „light pooling“ (Pooling nur in ausgewählten Blöcken). Alle anderen Hyperparameter bleiben gleich.

# H10: Pooling schedule — lighter pooling for 5 blocks (→16×16), compare to pool-all (→4×4)
name = "exp2/h10_depth5_light_pool_bn_aug_do"
h10 = run_flex_experiment(
    name,
    n_layers=5,
    n_filters=[32, 64, 128, 128, 128],
    kernel_sizes=[3]*5,
    pool_schedule=[True, True, True, False, False],
    dropout_rate=0.5,
    fc_size=256,
    model_class=FlexCNN_BN,
    dataloader=trainloader_with_aug,
    lr=0.02,
    epochs=100,
)
report_best(name, h10)
all_runs.append((name, h10))  # Track results for consolidated summary
exp2/h10_depth5_light_pool_bn_aug_do: train acc 64.973% | val acc 22.676%

exp2/h10_depth5_light_pool_bn_aug_do: best@epoch 98 | val_acc=35.277% | train_acc=64.272% | val_loss=2.7126 | train_loss=1.1915

Ergebnis:
- Val Accuracy: ~35.3 % (praktisch identisch zu H5)
- Train Accuracy: deutlich höher (64.97 % vs. 43.24 % in H5)
- Val Loss: ähnlich wie in H5
- Erkennbar stärkeres Overfitting, aber kein Val-Gewinn

Reflexion:
Die Hypothese wird bestätigt:
Light pooling bringt keinen Vorteil – das Modell lernt zwar schneller zu memorisieren (höhere Train Acc), aber generalisiert nicht besser.
Die zusätzliche räumliche Auflösung führt also nur zu mehr Kapazität im Classifier, nicht zu besseren Merkmalen.

H11 – Adam vs. SGD

Hypothese:
Adam sollte bei gleichem Setup schneller zu einer brauchbaren Validation Accuracy kommen als SGD.
Langfristig erwarten wir eine ähnliche oder leicht bessere finale Val Accuracy, aber vor allem weniger Epochen bis zu einem „ok“ Niveau.

Referenz: Adam: A Method for Stochastic Optimization, https://arxiv.org/abs/1412.6980

Experiment:
Wir nehmen das stabilisierte Rezept (FlexCNN_BN + Augmentation + Dropout) aus den vorherigen Runs und wechseln nur den Optimizer:

  • statt SGD(lr=0.02)
  • jetzt Adam(lr=0.002)

Alle anderen Hyperparameter, Architektur, DataLoader usw. bleiben unverändert.

# H11: Adam vs SGD under stabilized recipe, 3 blocks
name = "exp2/h11_adam_bn_aug_do"
h11 = run_flex_experiment(
    name,
    n_layers=3,
    n_filters=[32, 64, 128],
    kernel_sizes=[3, 3, 3],
    pool_schedule=[True, True, True],
    dropout_rate=0.5,
    fc_size=256,
    model_class=FlexCNN_BN,
    dataloader=trainloader_with_aug,
    optimizer_name="Adam",
    lr=0.002,
    epochs=100,
)
report_best(name, h11)
all_runs.append((name, h11))  # Track results for consolidated summary
exp2/h11_adam_bn_aug_do: train acc 1.125% | val acc 1.296%

exp2/h11_adam_bn_aug_do: best@epoch 3 | val_acc=1.296% | train_acc=1.097% | val_loss=4.7789 | train_loss=4.7855

Ergebnis:
Das Modell zeigt praktisch keinerlei Lernfortschritt:

  • Train Accuracy: ~1.1 %
  • Val Accuracy: ~1.3 %
  • Loss: bleibt konstant bei ~4.78 (≈ uniform guessing über 120 Klassen)
  • Beste Epoche: bereits Epoche 3, danach effektiv flach

Das Modell kommt also gar nicht erst von der Zufallsinitialisierung weg.

Reflexion:
Die Hypothese wird in diesem Setup nicht bestätigt, aber das liegt nicht daran, dass Adam grundsätzlich schlechter wäre.
Das Problem ist die zu niedrige Learning Rate.

Die bekannte Faustregel „Adam ≈ 10× kleinere LR als SGD“ ist nicht universell:
In stark regularisierten CNNs mit BatchNorm, Dropout und Augmentation sind die anfänglichen Gradienten vergleichsweise klein.
Dadurch benötigt Adam eine höhere LR, um seine adaptiven Schrittweiten sinnvoll aufzubauen.

Mit lr=0.002 waren die Updates zu schwach → das Modell bleibt beim Startpunkt stehen und lernt nichts.
Wir haben Adam damit nicht „fair“ gegen SGD getestet.

Um die Hypothese wirklich zu evaluieren, müssten wir Adam mit einer spürbar höheren LR testen (z. B. 0.005–0.01).
Da dieser Run 3 Stunden gedauert hat, verzichten wir hier darauf, den LR zu optimieren.
Das Experiment bleibt trotzdem wertvoll, da es zeigt, wie sensibel Adam in stark regularisierten CNN-Setups auf die LR reagiert.

H12 – Höhere Learning Rate mit BN ausprobieren

Hypothese:
Mit BatchNorm, Augmentation und Dropout können wir die Learning Rate moderat erhöhen (z. B. von 0.02 auf 0.03), ohne die Stabilität zu verlieren.
Grössere Schritte wirken dabei oft als implizite Regularisierung, weil sie das Modell zwingen, breite und stabilere Minima zu finden und schmale, überfittende Lösungen zu vermeiden.
Dadurch könnte sich die beste Validation Accuracy leicht verbessern.

Referenz: On Large-Batch Training for Deep Learning: Generalization Gap and Sharp Minima, https://arxiv.org/abs/1609.04836

Experiment:
Wir übernehmen das stabile Setup aus H5 (moderate Depth, BN, Dropout, Augmentation) und erhöhen nur die Learning Rate:

  • bisher: lr = 0.02
  • jetzt: lr = 0.03

Alle anderen Hyperparameter bleiben gleich.

# H12: Higher LR with BN (0.03 vs 0.02), stabilized recipe, 3 blocks
name = "exp2/h12_bn_aug_do_lr0p03"
h12 = run_flex_experiment(
    name,
    n_layers=3,
    n_filters=[32, 64, 128],
    kernel_sizes=[3, 3, 3],
    pool_schedule=[True, True, True],
    dropout_rate=0.5,
    fc_size=256,
    model_class=FlexCNN_BN,
    dataloader=trainloader_with_aug,
    lr=0.03,
    epochs=100,
)
report_best(name, h12)
all_runs.append((name, h12))  # Track results for consolidated summary
exp2/h12_bn_aug_do_lr0p03: train acc 23.400% | val acc 16.780%

exp2/h12_bn_aug_do_lr0p03: best@epoch 98 | val_acc=17.201% | train_acc=21.567% | val_loss=3.5445 | train_loss=3.1385

Ergebnis:
- Val Accuracy: ~17.2 % (klar schlechter als H5/H6 mit lr=0.02 → ~35 %)
- Train Accuracy: ~21.6 % (ebenfalls tiefer als H5/H6)
- Val Loss: höher und unruhiger
- Die Accuracy steigt deutlich langsamer und instabiler als mit lr=0.02

Reflexion:
Die Hypothese wird nicht bestätigt.
Obwohl BN grundsätzlich grössere Learning Rates verträgt und hohe LR in vielen Modellen als implizite Regularisierung wirkt, ist lr = 0.03 für diese spezifische Architektur zu aggressiv.
Das Modell wird instabiler, lernt weniger, und landet in einem schlechteren Minimum.
Damit bleibt lr = 0.02 die stabilste und effektivste Wahl im aktuellen Setup.

Gesamtfazit zu H1–H12

Über alle Experimente hinweg zeigt sich ein klares Bild:
Unser Modell profitiert am stärksten von angemessener Tiefe (5 Blöcke), BatchNorm, Dropout, Augmentation und einer moderat gewählten Learning Rate (0.02).
Die wichtigsten Erkenntnisse im Überblick:

1. Tiefe bringt viel – aber nur bis zu einem Punkt

  • H05 (depth=5) liefert mit ~35 % die beste Validation Accuracy.
  • H06 (depth=8) lernt zwar sehr gut auf dem Trainset, fällt aber in der Validation zurück → zu tief für dieses Setting.
  • Zu flache Modelle (H1–H3) bleiben deutlich unter ~20 %.

→ Sweet spot: 5 Conv-Blöcke.

2. BatchNorm + Dropout + Augmentation sind zentral

Die stabilisierte Kombination aus: - BatchNorm
- 0.5 Dropout
- stärkere Augmentation

führt konsistent zu den besten Resultaten.
Ohne BN (H02) oder ohne Dropout (H01) bricht die Generalisierung stark ein.

→ Regularisierung ist Pflicht.

3. Kernelgrössen: 3×3 bleibt die beste Wahl

  • 3×3 (H05, H06) outperformt
  • 5×5 (H09) klar unterlegen
  • grössere Kernels liefern keine besseren Features, nur mehr Parameter

→ 3×3 sind effizienter und stabiler.

4. Pooling-Strategie: light pooling bringt keinen Vorteil

  • H10 (light pooling) overfittet stärker, aber generalisiert nicht besser als H05.
  • volle Pooling-Frequenz bleibt überlegen.

→ Pooling nach jedem Block bleibt sinnvoll.

5. Width-Experimente: mehr Filter heisst nicht besser

  • moderate Breite funktioniert (H05)
  • sehr breite Modelle (H07, H08) kollabieren oder lernen kaum

→ Kapazität sinnvoll begrenzen.

6. Learning Rate: 0.02 ist optimal – 0.03 zu gross

  • H12 (lr=0.03) fällt deutlich ab und lernt schlechter.
  • lr=0.02 bleibt der stabile, beste Wert.

→ höher ≠ besser; LR-Sensitivität ist hoch.

7. Adam vs SGD: Adam wurde zu niedrig dosiert

  • H11 zeigt kein Lernen, da lr=0.002 für dieses Setup zu klein ist.
  • Adam wurde damit nicht fair getestet; SGD lr=0.02 bleibt führend.

→ Optimizer müssen LR-getuned werden; SGD funktioniert hier zuverlässig.

Experiment Epoch Val Acc Train Acc Val Loss Train Loss
exp2/h05_depth5_bn_aug_do 89 35.439% 43.239% 2.5250 2.0013
exp2/h10_depth5_light_pool_bn_aug_do 98 35.277% 64.272% 2.7126 1.1915
exp2/h06_depth8_bn_aug_do 98 28.215% 72.997% 4.1407 0.8605
exp2/h09_kernel_5x5_bn_aug_do 88 21.315% 22.942% 3.2321 3.0698
exp2/h03_aug_dropout_bn 97 17.687% 19.811% 3.4156 3.2025
exp2/h12_bn_aug_do_lr0p03 98 17.201% 21.567% 3.5445 3.1385
exp2/h02_aug_dropout 100 11.014% 16.528% 3.9853 3.5566
exp2/h01_aug_only 100 9.135% 79.064% 7.3772 0.7610
exp2/h07_width_moderate_bn_aug_do 99 5.475% 4.547% 4.2109 4.2535
exp2/h04_he_init_bn_aug_do 100 2.883% 2.249% 4.5288 4.6202
exp2/h08_width_large_bn_aug_do 93 2.786% 1.687% 4.6230 4.6708
exp2/h11_adam_bn_aug_do 3 1.296% 1.097% 4.7789 4.7855

Transfer Learning

Motivation

Unsere eigenen CNN-Experimente zeigen klar: Trotz vieler Architektur- und Trainingsanpassungen bleibt die Val-Accuracy tief. Das liegt weniger an einzelnen Hyperparametern, sondern daran, dass ein Modell from scratch zuerst alle grundlegenden visuellen Konzepte selbst lernen muss. Für eine feinkörnige Aufgabe wie 120 Hunderassen reicht dafür weder die Datengrösse noch die Trainingszeit.

Transfer Learning nutzt dagegen Modelle, die bereits auf ImageNet trainiert wurden – einem Datensatz mit über 1.2 Millionen Bildern und 1’000 Objektklassen. ImageNet deckt eine enorme Vielfalt visueller Strukturen ab (Tiere, Objekte, Texturen, Hintergründe), wodurch die Modelle sehr robuste Feature-Extraktoren entwickeln:

  • starke Kanten-, Textur- und Form-Features in frühen Layern,
  • allgemeine Objektkomponenten in mittleren Layern,
  • komplexe Mustererkennung in späteren Layern.

Diese Features sind universell und lassen sich gut auf neue Aufgaben übertragen.

Transfer Learning gibt uns einen bereits leistungsfähigen, auf Millionen Bildern trainierten Feature-Extraktor, den wir nur noch für unsere Hundeklassen feinjustieren müssen. So erreichen wir deutlich höhere Genauigkeit – bei viel geringerem Aufwand.

ResNet

Für das Transfer Learning wählen wir ein Modell aus der ResNet-Familie (Residual Networks). ResNets gehören zu den erfolgreichsten und am weitesten verbreiteten CNN-Architekturen im Computer Vision Bereich. Sie wurden 2015 von He et al. vorgestellt und gewannen den ImageNet-Wettbewerb mit deutlichem Abstand.

Der entscheidende Beitrag von ResNet ist die Einführung von Residual-Blöcken („skip connections“). Diese Verbindungen erlauben es, Informationen über mehrere Layer hinweg weiterzugeben, ohne dass der Gradient unterwegs „verpufft“. Dadurch lassen sich auch sehr tiefe Netzwerke stabil trainieren.

Vorteile eines ResNet-Modells für Transfer Learning: - robuste, generalisierende Features dank Training auf ImageNet
- stabile Optimierung durch Residual Connections
- gute Balance zwischen Modellgrösse und Performance
- etabliert, gut dokumentiert und in PyTorch direkt verfügbar

Für unsere Aufgabe reicht ein kleineres Modell wie ResNet18 vollkommen aus. Es ist schnell trainierbar, kompakt genug für GPUs mit wenig Speicher und liefert dennoch sehr starke Baseline-Features.

Referenz: Deep Residual Learning for Image Recognition, https://arxiv.org/abs/1512.03385

ResNet18

Bildgrösse und Normalisierung

Pretrained Modelle wie ResNet18 oder CoAtNet wurden auf ImageNet trainiert.
Damit diese Modelle ihr bereits gelerntes Wissen optimal nutzen können, müssen wir unsere Bilder so vorbereiten, wie es auch beim Pretraining der Fall war:

  1. Standardisierte Bildgrösse (meist 224×224)
    • CNNs wie ResNet sind relativ flexibel, aber transformerbasierte Modelle (z. B. CoAtNet) benötigen exakt jene räumliche Auflösung, auf der sie gelernt wurden.
    • Eine abweichende Grösse verändert das Token-Grid → führt schnell zu Performanceverlust.
  2. ImageNet-Normalisierung (mean & std)
    • Die Normalisierung verschiebt die Pixelwerte in denselben Zahlenbereich wie beim Pretraining.
    • Ohne passende Normalisierung „sieht“ das Modell systematisch verschobene Eingaben, was die Transferleistung reduziert.

Durch Resize auf 224×224 und ImageNet-Normalisierung stellen wir sicher, dass unsere Daten denselben statistischen Eigenschaften entsprechen wie beim ursprünglichen Training.

# ImageNet normalization used by both ResNet and CoAtNet pretrained weights
IMAGENET_MEAN = [0.485, 0.456, 0.406]
IMAGENET_STD = [0.229, 0.224, 0.225]

# Unified set of transforms tailored to the 224x224 transfer-learning pipelines.
train_transform = transforms.Compose([
    transforms.Resize((224, 224)),
    transforms.RandomHorizontalFlip(p=0.5),
    transforms.RandomApply([
        transforms.ColorJitter(brightness=0.4, contrast=0.4, saturation=0.4, hue=0.1)
    ], p=0.8),
    transforms.RandomRotation(15),
    transforms.ToTensor(),
    transforms.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),
])

val_transform = transforms.Compose([
    transforms.Resize((224, 224)),
    transforms.ToTensor(),
    transforms.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD),
])

# Wrap subsets with the correct transforms
trainset_with_aug = SubsetWithTransform(train_subset, train_transform)
validationset = SubsetWithTransform(val_subset, val_transform)
testset = SubsetWithTransform(test_subset, val_transform)

# Dataloaders configured for higher-resolution transfer learning experiments.
trainloader_with_aug = DataLoader(
    trainset_with_aug,
    batch_size=batch_size,
    shuffle=True,
    num_workers=NUM_WORKERS,
    persistent_workers=True,
    prefetch_factor=PREFETCH,
)

validationloader = DataLoader(
    validationset,
    batch_size=512,
    shuffle=False,
    num_workers=NUM_WORKERS,
    persistent_workers=True,
    prefetch_factor=PREFETCH,
)

testloader = DataLoader(
    testset,
    batch_size=512,
    shuffle=False,
    num_workers=NUM_WORKERS,
    persistent_workers=True,
    prefetch_factor=PREFETCH,
)

Overfitting im Transfer Learning

Pretrained Modelle sind stark – aber genau deshalb neigen sie beim Fine-Tuning auch schnell zum Overfitting, wenn wir sie ungefiltert auf kleinere Datensätze loslassen.
Wir haben unser Setup daher gezielt „gehärtet“, um das zu verhindern:

Modellseitige Massnahmen - Dropout vor dem Classifier (ResNet18: fc-Dropout, CoAtNet: drop_rate)
verhindert, dass sich die letzten Layer zu stark auf einzelne Features verlassen.
- Gewichtsregularisierung (höherer weight decay)
hält die Parameter kompakter und verhindert, dass das ganze Modell „wegdriftet“.
- Label Smoothing
reduziert zu starke Konfidenz in einzelnen Klassen und macht das Modell robuster.

Datenseitige Massnahmen - Stärkere Augmentation
(mehr Farb- und Geometrievariationen) sorgt für höhere Datenvielfalt pro Epoche.

Optimierungsseitige Massnahmen - Tieferes Fine-Tuning-Learning-Rate
damit der pretrained Backbone nicht zu weit von seinen ImageNet-Features abweicht.
- AdamW mit kleineren Schritten, wenn angebracht
verbessert Stabilität und Regularisierung im Finetuning.

# Transfer learning with ResNet18 (pretrained)
from torchvision import models


def run_resnet18_experiment(run_name, pretrained=True, freeze_backbone=False, optimizer_name="SGD", lr=None, epochs=None, dataloader=None, val_loader=None):
    """Fine-tune a ResNet-18 backbone with optional freezing and optimizer choices."""
    if lr is None:
        lr = 3e-3
    if epochs is None:
        epochs = 50
    if dataloader is None:
        dataloader = trainloader_with_aug
    if val_loader is None:
        val_loader = validationloader

    log_dir = os.path.join("runs", run_name)
    if os.path.isdir(log_dir):
        shutil.rmtree(log_dir)

    # Load pretrained weights when requested and replace the classifier with a dropout-regularized head.
    weights = models.ResNet18_Weights.DEFAULT if pretrained else None
    model = models.resnet18(weights=weights)
    model.fc = nn.Sequential(
        nn.Dropout(p=0.4),
        nn.Linear(model.fc.in_features, 120),
    )
    model = model.to(device)

    # Optionally freeze the backbone so only the classifier updates.
    if freeze_backbone:
        for p in model.parameters():
            p.requires_grad = False
        for p in model.fc.parameters():
            p.requires_grad = True
        params = model.fc.parameters()
    else:
        params = model.parameters()

    if optimizer_name.lower() == "adam":
        optimizer = optim.AdamW(params, lr=(lr / 10), weight_decay=3e-5)
    else:
        optimizer = optim.SGD(
            params,
            lr=lr,
            momentum=0.9,
            weight_decay=3e-4,
        )

    loss_fcn = nn.CrossEntropyLoss(label_smoothing=0.1)

    writer = SummaryWriter(log_dir=log_dir)
    history = train_model(
        model=model,
        optimizer=optimizer,
        train_dataloader=dataloader,
        n_epochs=epochs,
        loss_fcn=loss_fcn,
        device=device,
        val_dataloader=val_loader,
        writer=writer,
    )
    writer.close()
    final = history[-1]
    print(f"{run_name}: train acc {final['train_accuracy']:.3%} | val acc {final['val_accuracy']:.3%}")
    helper_utils.plot_learning_curves(run_name)
    return history

ResNet18 – Full Finetuning mit SGD

Für unseren ersten Transfer-Learning-Versuch führen wir ein full finetuning durch, das heisst:
Nicht nur der neue Classifier-Head wird trainiert, sondern alle Layer des Backbones werden erneut angepasst. Dadurch kann sich das Modell optimal an die feinkörnigen Strukturen der 120 Hunderassen anpassen.

# H13: Pretrained, full fine-tune
name = "exp2/h13_transfer_resnet18_pre_sgd"
h13 = run_resnet18_experiment(name, pretrained=True, freeze_backbone=False)
report_best(name, h13)
all_runs.append((name, h13))  # Track results for consolidated summary
exp2/h13_transfer_resnet18_pre_sgd: train acc 99.077% | val acc 72.562%

exp2/h13_transfer_resnet18_pre_sgd: best@epoch 17 | val_acc=74.668% | train_acc=91.941% | val_loss=1.6540 | train_loss=1.2382

Resultate

Der erste Transfer-Learning-Durchlauf mit ResNet18, komplett feinjustiert und mit SGD trainiert, zeigt einen massiven Sprung gegenüber allen vorherigen „from scratch“-Experimenten:

  • Train Accuracy: ~99 %
  • Validation Accuracy: ~72–75 % (Bestwert bei Epoche 17)
  • Val-Loss: stabilisiert sich früh bei ~1.65
  • Train-Loss: fällt kontinuierlich ab, ohne Anzeichen von Instabilität

Die Lernkurven zeigen einen raschen Performance-Anstieg in den ersten Epochen – typisch für Transfer Learning, da der Backbone bereits über gut ausgebildete ImageNet-Features verfügt. Die Validation-Accuracy flacht ab, bleibt aber stabil und weist deutlich weniger Schwankungen auf als in den früheren eigenen CNN-Versuchen.

ResNet18 – Full Finetuning mit AdamW

Im nächsten Schritt testen wir ResNet18 erneut, diesmal jedoch mit dem Optimizer AdamW. Während SGD oft als stabiler Standard im Transfer Learning gilt, bringt AdamW den Vorteil adaptiver Schrittweiten und einer sauberen, entkoppelten Weight-Decay-Regularisierung. Das macht ihn besonders geeignet für feinfühliges Fine-Tuning auf kleineren Datensätzen.

Beim full finetuning werden – wie zuvor – sämtliche Gewichte des ResNet18 aktualisiert. Durch AdamW erwarten wir:

  • schnellere Anfangskonvergenz,
  • feinere Anpassung an rassespezifische Details,
  • stabileres Verhalten dank korrekt angewendeter Regularisierung,
  • und potenziell bessere Generalisierung als mit Adam.

AdamW reagiert dennoch empfindlich auf Lernrate und Weight Decay, weshalb wir ein konservatives Setup wählen, um den pretrained Backbone nicht zu weit von seinen ImageNet-Features wegzutreiben. Dadurch können wir direkt vergleichen, wie sich AdamW gegenüber SGD und Adam hinsichtlich Stabilität und finaler Validation-Performance verhält.

# H14: Pretrained, full fine-tune adam
name = "exp2/h14_transfer_resnet18_pre_adam"
h14 = run_resnet18_experiment(name, optimizer_name="adam", pretrained=True, freeze_backbone=False)
report_best(name, h14)
all_runs.append((name, h14))  # Track results for consolidated summary
exp2/h14_transfer_resnet18_pre_adam: train acc 98.841% | val acc 65.533%

exp2/h14_transfer_resnet18_pre_adam: best@epoch 14 | val_acc=68.157% | train_acc=94.870% | val_loss=1.8547 | train_loss=1.1217

Resultate

Beim Fine-Tuning mit AdamW zeigt ResNet18 erneut sehr schnelle Konvergenz, allerdings mit anderen Charakteristiken als unter SGD:

  • Train Accuracy: ~99 %
  • Validation Accuracy: ~65–68 % (Bestwert um Epoche 14)
  • Train-Loss: fällt schneller als bei SGD
  • Val-Loss: stabil, aber leicht höher als in der SGD-Variante

Auffällig ist, dass Adam den Backbone sehr schnell anpasst: Die Accuracy springt bereits in den ersten wenigen Epochen auf ein hohes Niveau. Gleichzeitig bleibt die Validation-Accuracy aber konstant unter der SGD-Version (≈ 75 %).
Dies deutet auf einen typischen Effekt hin:

Adam passt sich aggressiver an die Trainingsdaten an und erreicht schneller perfektes Training, generalisiert aber schlechter.

Die Lernkurven zeigen zudem, dass die Validation schon ab Epoche 10 kaum noch steigt, obwohl der Train-Loss weiter sinkt – ein weiteres Zeichen für Overfitting, verstärkt durch Adams präzise, adaptive Updates.

Insgesamt ist AdamW im Full-Finetuning zwar sehr effizient, aber SGD liefert die klar bessere Validation-Performance und bleibt damit der bevorzugte Optimizer für diesen Transfer-Learning-Setup.

ResNet18 – SGD mit eingefrorenem Backbone

Als nächstes testen wir eine leichtere Variante des Transfer Learnings:
Der ResNet18-Backbone bleibt vollständig eingefroren, und nur der neue Klassifikations-Head wird trainiert. Dieses Setup ist deutlich effizienter, weil:

  • der grosse Teil des Modells (die Feature-Extraktion) nicht mehr aktualisiert wird,
  • das Training schneller und ressourcenschonender ist,
  • und das Risiko sinkt, die gut vortrainierten ImageNet-Features zu „verlernen“.

Wir verwenden SGD als Optimizer, da er sich in den vorhergehenden Versuchen besser bewährt hat.

Mit diesem Ansatz prüfen wir, wie weit wir ausschliesslich mit den ImageNet-Features kommen, ohne den Backbone anzupassen. Das Experiment dient damit als wichtige Referenz, um zu verstehen, wie viel Leistung ResNet18 bereits „out of the box“ liefert – und wie viel echtes Feintuning zusätzlich bringt.

# H15: Pretrained, SGD, freeze backbone
name = "exp2/h15_transfer_resnet18_pre_sgd_frozen"
h15 = run_resnet18_experiment(name, pretrained=True, freeze_backbone=True)
report_best(name, h15)
all_runs.append((name, h15))  # Track results for consolidated summary
exp2/h15_transfer_resnet18_pre_sgd_frozen: train acc 62.016% | val acc 71.331%

exp2/h15_transfer_resnet18_pre_sgd_frozen: best@epoch 47 | val_acc=73.307% | train_acc=62.328% | val_loss=1.7811 | train_loss=2.1108

Resultate

Das Frozen-Backbone-Setup zeigt ein interessantes Muster:
Die Validation Accuracy (~73 %) liegt klar über der Training Accuracy (~62 %).
Auf den ersten Blick wirkt das ungewöhnlich – ist aber absolut plausibel und typisch für Transfer Learning mit eingefrorenen Features.

Der Grund liegt in der Asymmetrie zwischen Train- und Val-Dataloader:

  • Train-Set: enthält starke Augmentationen (z. B. Farb-Jitter, RandomCrop, Flip, ggf. Mixup/CutMix).
    → Die Bilder sind verzerrt, verschoben, teilweise stark verfremdet.
    → Der neue Linearkopf muss auf stark variierende Inputs reagieren und kann diese nicht vollständig trennen.
    → Ergebnis: niedrigere Training Accuracy.

  • Validation-Set: enthält keine Augmentation, nur Resize + Normalisierung.
    → Die Bilder sind „sauber“, klar strukturiert und ähnlich zu ImageNet.
    → Die eingefrorenen ResNet-Features funktionieren hier besonders gut.
    → Ergebnis: höhere Validation Accuracy.

Zusätzlich spielt der eingefrorene Backbone eine wichtige Rolle:

  • Der Head ist klein (nur ein Linear Layer → begrenzte Kapazität).
  • Er kann die stark augmentierten Trainingsbeispiele nicht perfekt memorisieren.
  • Auf den unverfremdeten Val-Bildern wirken die ImageNet-Features dagegen fast ideal.

Kurz gesagt:
Die Trainingsdaten sind durch Augmentation „schwieriger“ als die Validationsdaten. Ein eingefrorener, generalisierender Backbone + sauberere Val-Daten ⇒ Val Acc > Train Acc.

CoAtNet - Convolution und Attention

CoAtNet kombiniert die Stärken von Convolutional Neural Networks (CNNs) mit den Vorteilen von Transformer-Modellen. Dadurch erreicht es sowohl starke Generalisierung bei kleineren Datenmengen als auch sehr hohe Leistungsfähigkeit bei großen Problemen.

Die drei wichtigsten Ideen hinter CoAtNet

  1. Lokaler Fokus durch Convolutions CNNs erfassen lokale Muster wie Kanten, Texturen und Formen sehr zuverlässig.
    CoAtNet nutzt diese Eigenschaft in den frühen Schichten, um robuste Grundmerkmale zu lernen.

  2. Globales Verständnis durch Self-Attention Transformer-Blöcke (Self-Attention) sehen das ganze Bild gleichzeitig.
    Dadurch erkennt das Modell größere Zusammenhänge wie Körperhaltung oder räumliche Beziehungen.

CoAtNet kombiniert Self-Attention mit relativem Positionsbias, sodass das Modell versteht, wo sich Bildteile relativ zueinander befinden.

  1. Hybride Architektur – zuerst Convs, dann Attention CoAtNet stapelt seine Bausteine in einer logischen Reihenfolge:
  • Am Anfang: Convolution-Blöcke → stabil und daten-effizient
  • Später: Attention-Blöcke → große Modellkapazität und starke globale Features

Warum CoAtNet bei unserem Datensatz gut funktionieren sollte

  • Es lernt feine lokale Details (z. B. Fell, Ohrenform, Muster).
  • Gleichzeitig erkennt es globale Strukturen (z. B. ganze Hundeschnauze, Pose).
  • Durch die Kombination aus Bias (CNN) und Kapazität (Attention) generalisiert es besser als reine CNNs oder reine Transformer.

Referenz: CoAtNet: Marrying Convolution and Attention for All Data Sizes, https://arxiv.org/abs/2106.04803

CoAtNet
# H16: CoAtNet, pretrained, full fine-tune, Adam, reasonable LR/epochs
import timm


def run_coatnet_experiment(
    run_name,
    model_name="coatnet_0_rw_224.sw_in1k",  # small-ish, good starting point
    pretrained=True,
    freeze_backbone=False,
    optimizer_name="adam",
    lr=None,
    epochs=None,
    dataloader=None,
    val_loader=None,
):
    """Fine-tune a timm-based CoAtNet model with flexible optimizer/backbone-freeze options."""
    if lr is None:
        lr = 5e-4  # smaller LR than ResNet, good for pretrained ViT-style models
    if epochs is None:
        epochs = 50  # CoAtNet is heavier; 50 is a reasonable starting point
    if dataloader is None:
        dataloader = trainloader_with_aug
    if val_loader is None:
        val_loader = validationloader

    log_dir = os.path.join("runs", run_name)
    if os.path.isdir(log_dir):
        shutil.rmtree(log_dir)

    # Create the CoAtNet backbone with the correct classifier shape upfront.
    model = timm.create_model(
        model_name,
        pretrained=pretrained,
        num_classes=120,
        drop_rate=0.4,
    )
    model = model.to(device)

    if freeze_backbone:
        # Freeze everything except the classifier head
        for name, p in model.named_parameters():
            p.requires_grad = False
        # timm models use `head` as classifier for coatnet
        for p in model.get_classifier().parameters():
            p.requires_grad = True
        params = model.get_classifier().parameters()
    else:
        params = model.parameters()

    if optimizer_name.lower() == "adam":
        optimizer = optim.AdamW(params, lr=(lr / 5), weight_decay=3e-5)
    else:
        optimizer = optim.SGD(params, lr=lr, momentum=0.9, weight_decay=5e-4)

    loss_fcn = nn.CrossEntropyLoss(label_smoothing=0.1)

    writer = SummaryWriter(log_dir=log_dir)
    history = train_model(
        model=model,
        optimizer=optimizer,
        train_dataloader=dataloader,
        n_epochs=epochs,
        loss_fcn=loss_fcn,
        device=device,
        val_dataloader=val_loader,
        writer=writer,
    )
    writer.close()
    final = history[-1]
    print(f"{run_name}: train acc {final['train_accuracy']:.3%} | val acc {final['val_accuracy']:.3%}")
    helper_utils.plot_learning_curves(run_name)
    return history

# H16: CoAtNet, pretrained, full fine-tune, Adam, reasonable LR/epochs
name = "exp2/h16_transfer_coatnet0_pre_adam_freeze"
h16 = run_coatnet_experiment(
    name,
    optimizer_name="adam",
    lr=3e-4,
    pretrained=True,
    freeze_backbone=True,   # full fine-tune
    epochs=50,
)
report_best(name, h16)
all_runs.append((name, h16))  # Track results for consolidated summary
exp2/h16_transfer_coatnet0_pre_adam_freeze: train acc 90.150% | val acc 91.383%

exp2/h16_transfer_coatnet0_pre_adam_freeze: best@epoch 40 | val_acc=91.869% | train_acc=89.560% | val_loss=1.0637 | train_loss=1.1362

Resultate

Das Frozen-Backbone-Setup mit CoAtNet-0 liefert das stärkste Transfer-Learning-Ergebnis:
Validation Accuracy ~91 % bei gleichzeitig hoher Trainings-Accuracy (~89 %) – ein fast perfektes Matching der beiden Kurven.

Die Lernverläufe zeigen:

  • Extrem schnelle Konvergenz bereits in den ersten 5–10 Epochen (typisch für Transformer-kompatible Hybridmodelle).
  • Sehr ähnliche Loss- und Accuracy-Kurven zwischen Train und Val → deutliche Robustheit gegenüber Overfitting.
  • Der Vorteil des eingefrorenen CoAtNet-Backbones wird sichtbar: Die vortrainierten, hierarchisch-attentiven Features passen perfekt zur Hunderassenstruktur.

Dass die Validation Accuracy leicht höher als die Training Accuracy liegt, erklärt sich wie beim ResNet-Frozen-Setup: - Das Training verwendet starke Augmentationen → „schwierigere“ Trainingsdaten.
- Die Validation zeigt sauberere, ImageNet-ähnliche Inputs → die CoAtNet-Features entfalten ihre volle Wirkung.

Unterm Strich demonstriert CoAtNet-0 in diesem einfachen Frozen-Backbone-Setting bereits ein exzellentes Transfer-Baseline-Niveau, das die CNN-Variante deutlich übertrifft.

Gesamtfazit der Transfer-Learning-Experimente

Die vier Transfer-Learning-Setups zeigen sehr deutlich, wie stark sich Architektur, Optimizer und der Umgang mit dem Backbone auf die finale Performance auswirken.

1. Full Finetuning mit ResNet18 (SGD oder Adam)
Beide Varianten profitieren stark vom vollständigen Entfrieren des Backbones: Die Modelle lernen rassespezifische Details effizient nach und erreichen solide Validation-Werte im Bereich um 70 %. SGD liefert dabei die stabilere Endleistung, während Adam schneller konvergiert, aber etwas stärker zum Overfitting neigt. Insgesamt zeigt sich: ResNet18 ist ein robuster, aber architekturbedingt limitierter Transfer-Learner.

2. Frozen ResNet18 mit SGD
Überraschend schlägt das Setup mit eingefrorenem Backbone die vollständig finetuneten Varianten leicht. Die starken, ImageNet-geprägten Features des ResNet18 passen gut zur Dog-Classification, und die Train-Augmentationen erklären die höhere Validation Accuracy. Das bestätigt einen bekannten Effekt: Bei kleineren Datensätzen kann ein konservativer, „festgefrorener“ Feature-Extraktor oft besser generalisieren als aggressives Finetuning.

3. Frozen CoAtNet-0 mit AdamW
CoAtNet setzt sich klar an die Spitze. Schon ohne Finetuning der tiefen Layer erreicht das Modell überragende Validation-Werte. Die Kombination aus Convolution, skalierter Self-Attention und den starken Features aus dem ImageNet-Pretraining harmoniert perfekt mit den Hunderassen-Daten. AdamW sorgt zusätzlich für stabile und schnelle Konvergenz. Dieses Ergebnis zeigt die enorme Transferfähigkeit moderner Hybrid-Modelle.

Experiment Epoch Val Acc Train Acc Val Loss Train Loss
exp2/h16_transfer_coatnet0_pre_adam_freeze 40 91.869% 89.560% 1.0637 1.1362
exp2/h13_transfer_resnet18_pre_sgd 17 74.668% 91.941% 1.6540 1.2382
exp2/h15_transfer_resnet18_pre_sgd_frozen 47 73.307% 62.328% 1.7811 2.1108
exp2/h14_transfer_resnet18_pre_adam 14 68.157% 94.870% 1.8547 1.1217

Training des Best Models

Final Training Pipeline – Warm-up, Unfreeze & Cosine Finetuning

Setup

Nach den Transfer-Learning-Vergleichen trainieren wir nun das beste Modell aus allen vorherigen Experimenten noch einmal gezielt und möglichst optimal aus. Dazu verwenden wir folgendes zweistufiges Finetuning-Protokoll:

  1. Warm-up mit gefrorenem Backbone
    Zunächst trainieren wir nur den Klassifikations-Head. Dadurch stabilisiert sich das Modell auf die neue Datenverteilung, ohne die tiefen Features des Pretrainings zu stören.

  2. Controlled Unfreeze
    Anschließend wird der gesamte Backbone wieder freigegeben – jedoch mit zwei unterschiedlichen Lernraten:

    • eine kleine LR für die tiefen, vortrainierten Layer (Feature-Stabilität),
    • eine höhere LR für den Klassifikations-Head (Task-Anpassung).
      Ein Cosine-Learning-Rate-Schedule reduziert die LR über die Zeit und sorgt für eine sanfte, stetige Feinjustierung der Gewichte.
  3. Training auf Train + Val
    Für die finale Modellversion nutzen wir alle verfügbaren gelabelten Daten (Train + Validation). Das erhöht die effektive Datenmenge und verbessert die Generalisierung für den finalen Test.

  4. Finale Evaluation auf dem Testset
    Zum Schluss wird ausschließlich das Testset verwendet, um eine faire Einschätzung der echten Modellleistung zu erhalten.

Referenz: Universal Language Model Fine-Tuning, https://arxiv.org/abs/1801.06146

# Build a combined (train + val) dataset for the final model that will later be evaluated on the untouched test set.
from torch.utils.data import ConcatDataset

trainval_set = ConcatDataset([
    SubsetWithTransform(train_subset, train_transform),
    SubsetWithTransform(val_subset, train_transform),
])
trainval_loader = DataLoader(
    trainval_set,
    batch_size=64,
    shuffle=True,
    num_workers=NUM_WORKERS,
    persistent_workers=True,
    prefetch_factor=PREFETCH,
)

# Final CoAtNet fine-tune

def run_coatnet_final(
    run_name="exp2/final_coatnet0",
    warmup_epochs=5,
    total_epochs=40,
    head_lr=1e-4,
    backbone_lr=2e-5,
):
    log_dir = os.path.join("runs", run_name)
    if os.path.isdir(log_dir):
        shutil.rmtree(log_dir)

    model = timm.create_model(
        "coatnet_0_rw_224.sw_in1k",
        pretrained=True,
        num_classes=120,
        drop_rate=0.4,
    ).to(device)

    loss_fcn = nn.CrossEntropyLoss(label_smoothing=0.1)

    writer = SummaryWriter(log_dir=log_dir)
    history = []

    # 1) Warm-up: freeze backbone, train head only
    for p in model.parameters():
        p.requires_grad = False
    for p in model.get_classifier().parameters():
        p.requires_grad = True

    optimizer = optim.AdamW(
        model.get_classifier().parameters(),
        lr=head_lr,
        weight_decay=3e-5,
    )
    warmup_hist = train_model(
        model=model,
        optimizer=optimizer,
        train_dataloader=trainval_loader,
        n_epochs=warmup_epochs,
        loss_fcn=loss_fcn,
        device=device,
        val_dataloader=None,
        writer=writer,
    )
    history.extend(warmup_hist)

    # 2) Unfreeze: discriminative LRs + Cosine schedule
    for p in model.parameters():
        p.requires_grad = True

    optimizer = optim.AdamW(
        [
            {"params": model.get_classifier().parameters(), "lr": head_lr},
            {"params": (p for name, p in model.named_parameters() if "head" not in name), "lr": backbone_lr},
        ],
        weight_decay=3e-5,
    )
    cosine_epochs = total_epochs - warmup_epochs
    scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=cosine_epochs)

    finetune_hist = train_model(
        model=model,
        optimizer=optimizer,
        train_dataloader=trainval_loader,
        n_epochs=cosine_epochs,
        loss_fcn=loss_fcn,
        device=device,
        val_dataloader=None,
        writer=writer,
        lr_scheduler=scheduler,
    )
    history.extend(finetune_hist)
    writer.close()

    # 3) Final evaluation on the untouched test set
    test_loss, test_acc = validate_epoch(
        model=model,
        dataloader=testloader,
        loss_fcn=loss_fcn,
        device=device,
    )
    print(f"[FINAL] test_acc={test_acc:.3%} | test_loss={test_loss:.4f}")

    helper_utils.plot_learning_curves(run_name)
    return history, model


final_history, final_model = run_coatnet_final()
[FINAL] test_acc=92.323% | test_loss=1.0409
/Users/stefanbinkert/Documents/FHNW_DS/DEL/DEL_DOGS/helper_utils.py:504: UserWarning: The palette list has more values (2) than needed (1), which may not be intended.
  sns.lineplot(
/Users/stefanbinkert/Documents/FHNW_DS/DEL/DEL_DOGS/helper_utils.py:504: UserWarning: The palette list has more values (2) than needed (1), which may not be intended.
  sns.lineplot(

Resultate

Das finale Training unseres besten Modells – CoAtNet0, trainiert mit einem kurzen Warm-up (Backbone gefroren), anschließendem gestuften Unfreeze und einem Cosine-Learning-Rate-Schedule – liefert ein sehr starkes Endresultat:

  • Test Accuracy: 92.323 %
  • Test Loss: 1.0409

Die Trainingskurven bestätigen die Stabilität des gesamten Finetuning-Prozesses:

  • Der Loss fällt schnell und kontinuierlich, ohne spätere Instabilitäten.
  • Die Train Accuracy steigt rasch über 95 % und erreicht nahezu Sättigung.
  • Kein erkennbares Overfitting: Die Test-Performance liegt nahe an der besten Validierungs-Accuracy aus den Transfer-Experimenten (~91.9 %).
  • Das Feintuning auf Train + Val hat also zuverlässig generalisiert.

Absicherung des finalen Modells

Da der Rechner nach dem 3 h Training bereits erste Stabilitätsanzeichen zeigte (Überhitzung, Speicherlast etc.), speichern wir das finale Modell explizit ab, um das Ergebnis dauerhaft zu sichern und jederzeit reproduzierbar laden zu können.

Speichern!
# Persist the trained model along with metadata for reproducible inference later on.
def save_checkpoint(model, path="models/final_coatnet0.pt"):
    """Serialize the model weights plus class/index metadata."""
    os.makedirs(os.path.dirname(path), exist_ok=True)
    torch.save(
        {
            "state_dict": model.state_dict(),
            "class_to_idx": trainset.dataset.class_to_idx if hasattr(trainset, "dataset") else None,
            "config": {
                "model_name": "coatnet_0_rw_224.sw_in1k",
                "img_size": 224,
                "num_classes": 120,
            },
        },
        path,
    )
    print(f"Checkpoint saved to {path}")


# Save the final coatnet_0 model for deployment.
save_checkpoint(final_model)
Checkpoint saved to models/final_coatnet0.pt
# Save the dataset's class/index mapping to JSON for downstream inference scripts.
dataset = DogDataset()  # uses data/raw/Images by default
os.makedirs("models", exist_ok=True)
with open("models/class_to_idx.json", "w") as fp:
    json.dump(dataset.class_to_idx, fp, indent=2)
print("Saved", len(dataset.class_to_idx), "classes")
Saved 120 classes

Analyse falsch klassifizierter Beispiele

Zum Abschluss werfen wir einen Blick auf einige falsch klassifizierte Testbilder unseres finalen CoAtNet-Modells.
Solche Fehlklassifikationen sind besonders wertvoll, weil sie Hinweise darauf geben,

  • wo das Modell trotz hoher Gesamtgenauigkeit noch unsicher ist,
  • welche Rassen visuell besonders ähnlich sind,
  • ob spezifische Muster (Pose, Licht, Fellstruktur) zu Verwechslungen führen,
  • und ob einzelne Klassen im Datensatz systematisch schwieriger sind.

Durch die Betrachtung dieser Beispiele können wir besser einschätzen,
wie robust unser Modell wirklich ist — und ob weitere Schritte wie
Datenaugmentation, Class-rebalancing oder architekturelle Anpassungen sinnvoll wären.

# Inspect misclassified examples to understand remaining failure modes.
def collect_misclassified(model, dataloader, max_items=16):
    model.eval()
    mistakes = []
    with torch.no_grad():
        for images, labels in dataloader:
            images = images.to(device)
            labels = labels.to(device)
            logits = model(images)
            probs = torch.softmax(logits, dim=1)
            conf, preds = probs.max(dim=1)
            wrong = preds != labels
            for img, pred, true, p in zip(images[wrong], preds[wrong], labels[wrong], conf[wrong]):
                mistakes.append((img.cpu(), pred.item(), true.item(), p.item()))
            if len(mistakes) >= max_items:
                break
    return mistakes[:max_items]


def prettify(label):
    raw = label.split("-", 1)[-1]  # drop WordNet prefix if present
    return raw.replace("_", " ").title()


def show_misclassified(mistakes, classes, n_cols=4):
    # Undo normalization so Matplotlib displays the original colors.
    inv_normalize = transforms.Normalize(
        mean=[-m / s for m, s in zip(mean, std)],
        std=[1 / s for s in std],
    )
    n_rows = math.ceil(len(mistakes) / n_cols)
    plt.figure(figsize=(3 * n_cols, 3 * n_rows))
    for idx, (img, pred, true, conf) in enumerate(mistakes):
        plt.subplot(n_rows, n_cols, idx + 1)
        viz = inv_normalize(img).clamp(0, 1).permute(1, 2, 0).numpy()
        plt.imshow(viz)
        plt.axis("off")
        plt.title(f"pred: {classes[pred]}
true: {classes[true]}
conf: {conf:.2f}")
    plt.tight_layout()


# Usage on validation or test loader:
mistakes = collect_misclassified(model, testloader, max_items=16)
base_ds = trainset_with_aug.subset.dataset
raw_names = getattr(base_ds, "classes", base_ds.class_names)
class_names = [prettify(name) for name in raw_names]

mistakes = collect_misclassified(model, testloader, max_items=16)
show_misclassified(mistakes, class_names)

1. Visuelle Ähnlichkeit zwischen Rassen

Viele Fehlklassifikationen treten bei Rassen auf, die sich auch für Menschen optisch stark ähneln:

  • Siberian Husky ↔︎ Eskimo Dog
  • Japanese Spaniel ↔︎ Blenheim Spaniel
  • German Shepherd ↔︎ Malinois

Das Modell verwechselt hier feine Unterschiede in Körperform, Fellzeichnung oder Kopfform – klassische „hard negatives“ in feingranularen Datensätzen.

2. Einfluss von Pose, Hintergrund oder verdeckten Merkmalen

Einige Bilder sind durch ungewöhnliche Posen, verdeckte Merkmale oder starke Bildartefakte schwer einzuordnen:

  • Springender Hund → verzerrte Körperformen
  • Hund mit Spielzeug → Gesicht teilweise verdeckt
  • Starker Schnee, Schatten oder Bewegungsunschärfe

Dadurch fehlen wichtige Merkmale, was zu plausiblen Fehlzuordnungen führt.

3. Unterschiedliche Konfidenzen

Die Fehlklassifikationen weisen teils sehr unterschiedliche Konfidenzwerte auf:

  • Hohe Confidence (0.7–0.9): Modell ist sicher, liegt aber bei sehr ähnlichen Rassen daneben
  • Geringe Confidence (0.2–0.5): Unsicherheit bei visuell schwachen oder uneindeutigen Bildern

Das zeigt, dass die Konfidenz nicht zufällig ist, sondern meist die Schwierigkeit des Bildes widerspiegelt.

4. Terrier-Cluster: extrem feingranulare Klassen

Viele Fehler stammen aus der Gruppe der kleinen Terrier:

  • Lakeland ↔︎ Border Terrier
  • Norwich ↔︎ Norfolk
  • Staffordshire ↔︎ American Staffordshire

Diese Rassen unterscheiden sich oft nur durch subtile Details – selbst Fachleute würden hier teilweise falsch liegen.

Vorhersage mit gespeichertem Modell und eigenem Bild

Zum Schluss möchten wir unser gespeichertes CoAtNet0-Finalmodell verwenden, um eine Vorhersage auf einem eigenen Bild zu machen.
Dazu laden wir:

  1. das gespeicherte Modell,
  2. die passende Label-Mapping (Index → Klassenname),
  3. und wenden die gleichen Transformationen an wie im Testset
    (Resize/CenterCrop 224×224, ToTensor, ImageNet-Normalisierung).
# Load the saved model + class index mapping to run standalone inference.
import torch
import timm
from torchvision import transforms
from PIL import Image

ckpt = torch.load("models/final_coatnet0.pt", map_location="cpu")

cfg = ckpt["config"]
model = timm.create_model(
    cfg["model_name"],  # coatnet_0_rw_224.sw_in1k
    pretrained=False,
    num_classes=cfg["num_classes"],  # 120
)
model.load_state_dict(ckpt["state_dict"])
model.eval()

with open("models/class_to_idx.json") as fp:
    class_to_idx = json.load(fp)
idx_to_class = {idx: cls for cls, idx in class_to_idx.items()}

mean = [0.485, 0.456, 0.406]  # same values used during training (see augmentation cell)
std = [0.229, 0.224, 0.225]
inference_tfms = transforms.Compose([
    transforms.Resize(cfg["img_size"]),
    transforms.CenterCrop(cfg["img_size"]),
    transforms.ToTensor(),
    transforms.Normalize(mean, std),
])
# Classify a custom dog photo (resized to the trained resolution and stripped of alpha).
img = Image.open("pics/brick.png").resize((224, 224)).convert("RGB")
img

Das ist Brick, ein junger Border Collie eines Freundes.

Und hier die Classifikation des Modells…

# Run inference with softmax probabilities and pretty-print the predicted breed.
with torch.no_grad():
    logits = model(inference_tfms(img).unsqueeze(0))
    probs = torch.softmax(logits, dim=1)
    conf, pred_idx = probs.max(dim=1)
    # print(idx_to_class[pred_idx.item()], conf.item())
raw_name = idx_to_class[pred_idx.item()]  # e.g. "n02085936-Maltese_dog"
pretty = raw_name.split("-", 1)[-1].replace("_", " ")
print(pretty.title())
Border Collie

Fazit & Erkenntnisse

Im Folgenden die wichtigsten Erkenntnisse des Projekts.

Technisch:
  • Transfer Learning ist Pflicht bei komplexen Vision-Tasks.
  • Pretrained Modelle liefern nicht nur schnellere Konvergenz, sondern fundamental bessere Feature-Extraktion.
  • Moderne Hybride (CoAtNet) outperformen klassische CNNs wie ResNet klar.
  • BatchNorm + hohe LR = starke Stabilisierung + Regularisierung.
  • AdamW ≠ Adam → Weight Decay richtig angewendet macht einen Unterschied.
Methodisch:
  • Systematische Hypothesen-Tests (H1–H12) geben echten Erkenntnisgewinn.
  • Training from scratch ist ein wertvoller didaktischer Schritt – zeigt Grenzen & Notwendigkeiten auf.
  • Finale Performance entsteht erst durch gesamtheitliches Engineering:
    Augmentation, Optimizer, Scheduler, Freezing-Strategien, Architekturwahl.
Praktisch:
  • ~92% auf feingranularer Hundeklassifikation ist ein sehr starkes Resultat.
  • Das Modell ist robuster als erwartet – selbst auf „schwierigen“ eigenen Bildern.
  • Mit wenig zusätzlicher Arbeit (mehr Data, leichte Architektur-Tweaks) wäre sogar noch Luft nach oben.

Persönliche Erkenntnisse

  • Das Projekt zeigt exemplarisch, wie vielschichtig modernes Deep Learning ist.
  • Man entwickelt nicht „einfach ein Modell“, sondern ein komplexes Trainingssystem.
  • Die wertvollsten Progress-Sprünge entstehen oft nicht durch „mehr Training“, sondern durch strukturelle Entscheidungen:
    richtige Architektur, richtige LR, richtige Normalisierung, richtiger Optimizer.

Disclaimer

Ein Teil dieses Projekts wurde mit Unterstützung von ChatGPT und OpenAI Codex umgesetzt, insbesondere beim Debugging komplexerer Code-Abschnitte.

Das fachliche Fundament für die Modellarchitekturen, Trainingsmethoden und Evaluationsansätze basiert auf den Kursen:

  • DEL Deep Dive von Martin Melchior
  • Deep Learning Specialization von Andrew Ng
  • PyTorch for Deep Learning von Laurence Moroney

Diese beiden Weiterbildungen bildeten die Grundlage für das technische Verständnis und die Umsetzung aller Kernideen in diesem Projekt.